feat(radio): Radio component.

Closes #125
This commit is contained in:
Michael Lin
2016-02-24 15:07:55 -05:00
committed by Jeremy Elbourn
parent ad0c3eb801
commit d76465b12b
10 changed files with 823 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
@import "default-theme";
$md-radio-width: 20px !default;
// Top-level host container.
md-radio-button {
display: inline-block;
}
// Inner label container, wrapping entire element.
// Enables focus by click.
.md-radio-label {
cursor: pointer;
display: block;
padding: 8px;
white-space: nowrap;
}
// Container for radio circles and ripple.
.md-radio-container {
box-sizing: border-box;
display: inline-block;
height: $md-radio-width;
position: relative;
top: 2px;
width: $md-radio-width;
}
// TODO(mtlin): Replace when ink ripple component is implemented.
// A placeholder ink ripple, shown when keyboard focused.
.md-ink-ripple {
background-color: md-color($md-accent);
border-radius: 50%;
height: 48px;
left: 10px;
opacity: 0;
pointer-events: none;
position: absolute;
top: 10px;
transform: translate(-50%,-50%);
transition: opacity ease 0.28s, background-color ease 0.28s;
width: 48px;
overflow: hidden;
// Fade in when radio focused.
.md-radio-focused & {
opacity: 0.1;
}
// TODO(mtlin): This corresponds to disabled focus state, but it's unclear how to enter into
// this state.
.md-radio-disabled & {
background: #000;
}
}
// The outer circle for the radio, always present.
.md-radio-outer-circle {
border-color: md-color($md-foreground, secondary-text);
border: solid 2px;
border-radius: 50%;
box-sizing: border-box;
height: $md-radio-width;
left: 0;
position: absolute;
top: 0;
transition: border-color ease 0.28s;
width: $md-radio-width;
.md-radio-checked & {
border-color: md-color($md-accent);
}
.md-radio-disabled & {
border-color: md-color($md-foreground, disabled);
}
}
// The inner circle for the radio, shown when checked.
.md-radio-inner-circle {
background-color: md-color($md-accent);
border-radius: 50%;
box-sizing: border-box;
height: $md-radio-width;
left: 0;
position: absolute;
top: 0;
transition: transform ease 0.28s, background-color ease 0.28s;
transform: scale(0);
width: $md-radio-width;
.md-radio-checked & {
transform: scale(0.5);
}
.md-radio-disabled & {
background-color: md-color($md-foreground, disabled);
}
}
// Text label next to radio.
.md-radio-label-content {
display: inline-block;
float: right;
line-height: 24px;
// Equal padding on both sides, for RTL.
padding-left: 8px;
padding-right: 8px;
position: relative;
vertical-align: top;
}
// Underlying native input element.
// Visually hidden but still able to respond to focus.
.md-radio-input {
position: absolute;
width: 0;
height: 0;
margin: 0;
padding: 0;
opacity: 0;
appearance: none;
border: none;
}
// Basic disabled state.
.md-radio-disabled, .md-radio-disabled .md-radio-label {
cursor: default;
}
+24
View File
@@ -0,0 +1,24 @@
<!-- TODO(jelbourn): render the radio on either side of the content -->
<!-- TODO(mtlin): Evaluate trade-offs of using native radio vs. cost of additional bindings. -->
<label [attr.for]="inputId" class="md-radio-label">
<!-- The actual `radio` part of the control. -->
<div class="md-radio-container">
<div class="md-radio-outer-circle"></div>
<div class="md-radio-inner-circle"></div>
<div class="md-ink-ripple"></div>
</div>
<input #input class="md-radio-input" type="radio"
[id]="inputId"
[checked]="checked"
[disabled]="disabled"
[name]="name"
(change)="onInputChange()"
(focus)="onInputFocus()"
(blur)="onInputBlur()" />
<!-- The label content for radio control. -->
<div class="md-radio-label-content">
<ng-content></ng-content>
</div>
</label>
+292
View File
@@ -0,0 +1,292 @@
import {
fakeAsync,
inject,
tick,
TestComponentBuilder
} from 'angular2/testing';
import {
it,
describe,
expect,
beforeEach,
} from '../../core/facade/testing';
import {Component, DebugElement} from 'angular2/core';
import {By} from 'angular2/platform/browser';
import {MdRadioButton, MdRadioGroup, MdRadioChange} from './radio_button';
import {MdRadioDispatcher} from './radio_dispatcher';
export function main() {
describe('MdRadioButton', () => {
let builder: TestComponentBuilder;
beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => {
builder = tcb;
}));
it('should have same name as radio group', (done: () => void) => {
builder
.overrideTemplate(TestApp, `
<md-radio-group name="my_group">
<md-radio-button></md-radio-button>
</md-radio-group>`)
.createAsync(TestApp)
.then((fixture) => {
let button = fixture.debugElement.query(By.css('md-radio-button'));
fixture.detectChanges();
expect(button.componentInstance.name).toBe('my_group');
}).then(done);
});
it('should not allow click selection if disabled', (done: () => void) => {
builder
.overrideTemplate(TestApp, '<md-radio-button disabled></md-radio-button>')
.createAsync(TestApp)
.then((fixture) => {
let button = fixture.debugElement.query(By.css('md-radio-button'));
fixture.detectChanges();
expect(button.componentInstance.checked).toBe(false);
button.nativeElement.click();
expect(button.componentInstance.checked).toBe(false);
}).then(done);
});
it('should be disabled if radio group disabled', (done: () => void) => {
builder
.overrideTemplate(TestApp, `
<md-radio-group disabled>
<md-radio-button></md-radio-button>
</md-radio-group>`)
.createAsync(TestApp)
.then((fixture) => {
let button = fixture.debugElement.query(By.css('md-radio-button'));
fixture.detectChanges();
expect(button.componentInstance.disabled).toBe(true);
}).then(done);
});
it('updates parent group value when selected and value changed', (done: () => void) => {
builder
.overrideTemplate(TestApp, `
<md-radio-group>
<md-radio-button value="1"></md-radio-button>
</md-radio-group>`)
.createAsync(TestApp)
.then((fixture) => {
let button = fixture.debugElement.query(By.css('md-radio-button'));
let group = fixture.debugElement.query(By.css('md-radio-group'));
group.componentInstance.selected = button.componentInstance;
fixture.detectChanges();
expect(group.componentInstance.value).toBe('1');
button.componentInstance.value = '2';
fixture.detectChanges();
expect(group.componentInstance.value).toBe('2');
}).then(done);
});
it('should be checked after input change event', (done: () => void) => {
builder
.overrideTemplate(TestApp, '<md-radio-button></md-radio-button>')
.createAsync(TestApp)
.then((fixture) => {
let button = fixture.debugElement.query(By.css('md-radio-button'));
let input = button.query(By.css('input'));
fixture.detectChanges();
expect(button.componentInstance.checked).toBe(false);
let event = createEvent('change');
input.nativeElement.dispatchEvent(event);
expect(button.componentInstance.checked).toBe(true);
}).then(done);
});
it('should emit event when checked', (done: () => void) => {
builder
.overrideTemplate(TestApp, '<md-radio-button></md-radio-button>')
.createAsync(TestApp)
.then((fixture) => {
fakeAsync(function() {
let button = fixture.debugElement.query(By.css('md-radio-button'));
let changeEvent: MdRadioChange = null;
button.componentInstance.change.subscribe((evt: MdRadioChange) => {
changeEvent = evt;
});
button.componentInstance.checked = true;
fixture.detectChanges();
tick();
expect(changeEvent).not.toBe(null);
expect(changeEvent.source).toBe(button.componentInstance);
});
}).then(done);
});
it('should be focusable', (done: () => void) => {
builder
.overrideTemplate(TestApp, '<md-radio-button></md-radio-button>')
.createAsync(TestApp)
.then((fixture) => {
let button = fixture.debugElement.query(By.css('md-radio-button'));
let input = button.query(By.css('input'));
fixture.detectChanges();
expect(button.nativeElement.classList.contains('md-radio-focused')).toBe(false);
let event = createEvent('focus');
input.nativeElement.dispatchEvent(event);
fixture.detectChanges();
expect(button.nativeElement.classList.contains('md-radio-focused')).toBe(true);
event = createEvent('blur');
input.nativeElement.dispatchEvent(event);
fixture.detectChanges();
expect(button.nativeElement.classList.contains('md-radio-focused')).toBe(false);
}).then(done);
});
});
describe('MdRadioDispatcher', () => {
let builder: TestComponentBuilder;
let dispatcher: MdRadioDispatcher;
beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => {
builder = tcb;
dispatcher = new MdRadioDispatcher();
}));
it('notifies listeners', () => {
let notificationCount = 0;
const numListeners = 2;
for (let i = 0; i < numListeners; i++) {
dispatcher.listen(() => {
notificationCount++;
});
}
dispatcher.notify('hello');
expect(notificationCount).toBe(numListeners);
});
});
describe('MdRadioGroup', () => {
let builder: TestComponentBuilder;
beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => {
builder = tcb;
}));
it('can select by value', (done: () => void) => {
builder
.overrideTemplate(TestApp, `
<md-radio-group>
<md-radio-button value="1"></md-radio-button>
<md-radio-button value="2"></md-radio-button>
</md-radio-group>`)
.createAsync(TestApp)
.then((fixture) => {
let buttons = fixture.debugElement.queryAll(By.css('md-radio-button'));
let group = fixture.debugElement.query(By.css('md-radio-group'));
fixture.detectChanges();
expect(group.componentInstance.selected).toBe(null);
group.componentInstance.value = '2';
fixture.detectChanges();
expect(group.componentInstance.selected).toBe(buttons[1].componentInstance);
}).then(done);
});
it('should select uniquely', (done: () => void) => {
builder
.overrideTemplate(TestApp, `
<md-radio-group>
<md-radio-button></md-radio-button>
<md-radio-button></md-radio-button>
</md-radio-group>`)
.createAsync(TestApp)
.then((fixture) => {
let buttons = fixture.debugElement.queryAll(By.css('md-radio-button'));
let group = fixture.debugElement.query(By.css('md-radio-group'));
fixture.detectChanges();
expect(group.componentInstance.selected).toBe(null);
group.componentInstance.selected = buttons[0].componentInstance;
fixture.detectChanges();
expect(isSinglySelected(buttons[0], buttons)).toBe(true);
group.componentInstance.selected = buttons[1].componentInstance;
fixture.detectChanges();
expect(isSinglySelected(buttons[1], buttons)).toBe(true);
}).then(done);
});
it('should emit event when value changes', (done: () => void) => {
builder
.overrideTemplate(TestApp, `
<md-radio-group>
<md-radio-button></md-radio-button>
<md-radio-button></md-radio-button>
</md-radio-group>`)
.createAsync(TestApp)
.then((fixture) => {
fakeAsync(function() {
let buttons = fixture.debugElement.queryAll(By.css('md-radio-button'));
let group = fixture.debugElement.query(By.css('md-radio-group'));
let changeEvent: MdRadioChange = null;
group.componentInstance.change.subscribe((evt: MdRadioChange) => {
changeEvent = evt;
});
group.componentInstance.selected = buttons[1].componentInstance;
fixture.detectChanges();
tick();
expect(changeEvent).not.toBe(null);
expect(changeEvent.source).toBe(buttons[1].componentInstance);
});
}).then(done);
});
});
}
/** Checks whether a given button is uniquely selected from a group of buttons. */
function isSinglySelected(button: DebugElement, buttons: DebugElement[]): boolean {
let component = button.componentInstance;
let otherSelectedButtons =
buttons.filter((e: DebugElement) =>
e.componentInstance != component && e.componentInstance.checked);
return component.checked && otherSelectedButtons.length == 0;
}
/** Browser-agnostic function for creating an event. */
function createEvent(name: string): Event {
let ev: Event;
try {
ev = createEvent(name);
} catch (e) {
ev = document.createEvent('Event');
ev.initEvent(name, true, true);
}
return ev;
}
/** Test component. */
@Component({
directives: [MdRadioButton, MdRadioGroup],
providers: [MdRadioDispatcher],
template: ''
})
class TestApp {}
+306
View File
@@ -0,0 +1,306 @@
import {
AfterContentInit,
Component,
ContentChildren,
Directive,
EventEmitter,
HostBinding,
HostListener,
Input,
OnInit,
Optional,
Output,
QueryList,
ViewEncapsulation,
forwardRef
} from 'angular2/core';
import {Event} from 'angular2/src/facade/browser';
import {MdRadioDispatcher} from './radio_dispatcher';
// TODO(mtlin):
// Ink ripple is currently placeholder.
// Determine motion spec for button transitions.
// Design review.
// RTL
// Support forms API.
// Use ChangeDetectionStrategy.OnPush
var _uniqueIdCounter = 0;
/** A simple change event emitted by either MdRadioButton or MdRadioGroup. */
export class MdRadioChange {
source: MdRadioButton;
value: any;
}
@Directive({
selector: 'md-radio-group',
host: {
'role': 'radiogroup',
},
})
export class MdRadioGroup implements AfterContentInit {
/** The value for the radio group. Should match currently selected button. */
private _value: any = null;
/** The HTML name attribute applied to radio buttons in this group. */
private _name: string = null;
/** Disables all individual radio buttons assigned to this group. */
private _disabled: boolean = false;
/** The currently selected radio button. Should match value. */
private _selected: MdRadioButton = null;
/** Event emitted when the group value changes. */
@Output()
change: EventEmitter<MdRadioChange> = new EventEmitter();
/** Child radio buttons. */
@ContentChildren(forwardRef(() => MdRadioButton))
private _radios: QueryList<MdRadioButton> = null;
/**
* Initialize properties once content children are available.
* This allows us to propagate relevant attributes to associated buttons.
*/
ngAfterContentInit() {
if (this._name == null) {
this.name = `md-radio-group-${_uniqueIdCounter++}`;
} else {
this._updateChildRadioNames();
}
}
@Input()
get name(): string {
return this._name;
}
set name(value: string) {
this._name = value;
this._updateChildRadioNames();
}
/** Propagate name attribute to radio buttons. */
private _updateChildRadioNames(): void {
if (this._radios != null) {
this._radios.forEach((radio) => {
radio.name = this._name;
});
}
}
@Input()
get disabled(): boolean {
return this._disabled;
}
set disabled(value) {
// The presence of *any* disabled value makes the component disabled, *except* for false.
this._disabled = (value != null && value !== false) ? true : null;
}
@Input()
get value(): any {
return this._value;
}
set value(newValue: any) {
if (this._value != newValue) {
// Set this before proceeding to ensure no circular loop occurs with selection.
this._value = newValue;
this._updateSelectedRadioFromValue();
this._emitChangeEvent();
}
}
private _updateSelectedRadioFromValue(): void {
// Update selected if different from current value.
let isAlreadySelected = this._selected != null && this._selected.value == this._value;
if (this._radios != null && !isAlreadySelected) {
let matched = this._radios.filter((radio) => {
return radio.value == this._value;
});
if (matched.length == 0) {
// Didn't find a button that matches this value, return early without setting.
return;
}
// Change the selection immediately.
this.selected = matched[0];
}
}
/** Dispatch change event with current selection and group value. */
private _emitChangeEvent(): void {
let event = new MdRadioChange();
event.source = this._selected;
event.value = this._value;
this.change.emit(event);
}
@Input()
get selected() {
return this._selected;
}
set selected(selected: MdRadioButton) {
this._selected = selected;
this.value = selected.value;
selected.checked = true;
}
}
@Component({
selector: 'md-radio-button',
templateUrl: './components/radio/radio_button.html',
styleUrls: ['./components/radio/radio-button.css'],
encapsulation: ViewEncapsulation.None
})
export class MdRadioButton implements OnInit {
@HostBinding('class.md-radio-focused')
private _isFocused: boolean;
/** Whether this radio is checked. */
private _checked: boolean = false;
/** The unique ID for the radio button. */
@HostBinding('id')
@Input()
id: string;
/** Analog to HTML 'name' attribute used to group radios for unique selection. */
@Input()
name: string;
/** Whether this radio is disabled. */
private _disabled: boolean;
/** Value assigned to this radio.*/
private _value: any = null;
/** The parent radio group. May or may not be present. */
radioGroup: MdRadioGroup;
/** Event emitted when the group value changes. */
@Output()
change: EventEmitter<MdRadioChange> = new EventEmitter();
constructor(@Optional() radioGroup: MdRadioGroup, public radioDispatcher: MdRadioDispatcher) {
// Assertions. Ideally these should be stripped out by the compiler.
// TODO(jelbourn): Assert that there's no name binding AND a parent radio group.
this.radioGroup = radioGroup;
radioDispatcher.listen((name: string) => {
if (name == this.name) {
this.checked = false;
}
});
}
ngOnInit() {
if (this.id == null) {
this.id = `md-radio-${_uniqueIdCounter++}`;
}
}
/*
* We use a hidden native input field to handle changes to focus state via keyboard navigation,
* with visual rendering done separately. The native element is kept in sync with the overall
* state of the component.
*/
onInputFocus() {
this._isFocused = true;
}
onInputBlur() {
this._isFocused = false;
}
/** Input change handler, called only on keyboard selection. */
onInputChange() {
this.checked = true;
}
get inputId(): string {
return `${this.id}-input`;
}
@HostBinding('class.md-radio-checked')
@Input()
get checked(): boolean {
return this._checked;
}
set checked(value: boolean) {
if (value) {
// Notify all radio buttons with the same name to un-check.
this.radioDispatcher.notify(this.name);
if (!this._checked) {
this._emitChangeEvent();
}
}
this._checked = value;
}
/** MdRadioGroup reads this to assign its own value. */
@Input()
get value(): any {
return this._value;
}
set value(value: any) {
if (this._value != value) {
if (this.radioGroup != null && this.checked) {
this.radioGroup.value = value;
}
this._value = value;
}
}
/** Dispatch change event with current value. */
private _emitChangeEvent(): void {
let event = new MdRadioChange();
event.source = this;
event.value = this._value;
this.change.emit(event);
}
@HostBinding('class.md-radio-disabled')
@Input()
get disabled(): boolean {
return this._disabled || (this.radioGroup != null && this.radioGroup.disabled);
}
set disabled(value: boolean) {
// The presence of *any* disabled value makes the component disabled, *except* for false.
this._disabled = (value != null && value !== false) ? true : null;
}
@HostListener('click', ['$event'])
onClick(event: Event) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
if (this.radioGroup != null) {
// Propagate the change one-way via the group, which will in turn mark this
// button as checked.
this.radioGroup.selected = this;
} else {
this.checked = true;
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import {Injectable} from 'angular2/core';
/**
* Class for radio buttons to coordinate unique selection based on name.
* Intended to be consumed as an Angular service.
* This service is needed because native radio change events are only fired on the item currently
* being selected, and we still need to uncheck the previous selection.
*/
@Injectable()
export class MdRadioDispatcher {
// TODO(jelbourn): Change this to TypeScript syntax when supported.
private _listeners: Function[];
constructor() {
this._listeners = [];
}
/** Notify other radio buttons that selection for the given name has been set. */
notify(name: string) {
this._listeners.forEach(listener => listener(name));
}
/** Listen for future changes to radio button selection. */
listen(listener: (name: string) => void) {
this._listeners.push(listener);
}
}
+1
View File
@@ -8,6 +8,7 @@
<li><a [routerLink]="['PortalDemo']">Portal demo</a></li>
<li><a [routerLink]="['CheckboxDemo']">Checkbox demo</a></li>
<li><a [routerLink]="['ToolbarDemo']">Toolbar demo</a></li>
<li><a [routerLink]="['RadioDemo']">Radio demo</a></li>
</ul>
<button md-raised-button (click)="root.dir = (root.dir == 'rtl' ? 'ltr' : 'rtl')">
{{root.dir.toUpperCase()}}
+2
View File
@@ -2,6 +2,7 @@ import {Component} from 'angular2/core';
import {Route, RouteConfig, ROUTER_DIRECTIVES} from 'angular2/router';
import {CardDemo} from './card/card-demo';
import {ButtonDemo} from './button/button-demo';
import {RadioDemo} from './radio/radio-demo';
import {SidenavDemo} from './sidenav/sidenav-demo';
import {ProgressCircleDemo} from './progress-circle/progress-circle-demo';
import {CheckboxDemo} from './checkbox/checkbox-demo';
@@ -29,6 +30,7 @@ export class Home {}
new Route({path: '/', name: 'Home', component: Home, useAsDefault: true}),
new Route({path: '/button', name: 'ButtonDemo', component: ButtonDemo}),
new Route({path: '/card', name: 'CardDemo', component: CardDemo}),
new Route({path: '/radio', name: 'RadioDemo', component: RadioDemo}),
new Route({path: '/sidenav', name: 'SidenavDemo', component: SidenavDemo}),
new Route({path: '/progress-circle', name: 'ProgressCircleDemo', component: ProgressCircleDemo}),
new Route({path: '/portal', name: 'PortalDemo', component: PortalDemo}),
+18
View File
@@ -0,0 +1,18 @@
<section class="demo-section">
<md-radio-button name="group1">Option 1</md-radio-button>
<md-radio-button name="group1">Option 2</md-radio-button>
<md-radio-button name="group1" disabled="true">Option 3 (disabled)</md-radio-button>
</section>
<section class="demo-section">
<div>
<span>isDisabled: {{isDisabled}}</span>
<button md-raised-button (click)="isDisabled=!isDisabled" class="demo-button">
Disable buttons
</button>
</div>
<md-radio-group name="my_options" [disabled]="isDisabled">
<md-radio-button value="option_1">Option 1</md-radio-button>
<md-radio-button value="option_2">Option 2</md-radio-button>
<md-radio-button value="option_3">Option 3</md-radio-button>
</md-radio-group>
</section>
+10
View File
@@ -0,0 +1,10 @@
.demo-button {
margin: 8px;
text-transform: uppercase;
}
.demo-section {
background-color: #f7f7f7;
margin: 8px;
padding: 16px;
}
+14
View File
@@ -0,0 +1,14 @@
import {Component} from 'angular2/core';
import {MdRadioButton, MdRadioGroup} from '../../components/radio/radio_button';
import {MdRadioDispatcher} from '../../components/radio/radio_dispatcher';
@Component({
selector: 'radio-demo',
templateUrl: 'demo-app/radio/radio-demo.html',
styleUrls: ['demo-app/radio/radio-demo.css'],
providers: [MdRadioDispatcher],
directives: [MdRadioButton, MdRadioGroup]
})
export class RadioDemo {
isDisabled: boolean = false;
}