diff --git a/src/components/radio/radio-button.scss b/src/components/radio/radio-button.scss new file mode 100644 index 000000000000..22913dcae1de --- /dev/null +++ b/src/components/radio/radio-button.scss @@ -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; +} diff --git a/src/components/radio/radio_button.html b/src/components/radio/radio_button.html new file mode 100644 index 000000000000..3cc1c7065dd3 --- /dev/null +++ b/src/components/radio/radio_button.html @@ -0,0 +1,24 @@ + + + diff --git a/src/components/radio/radio_button.spec.ts b/src/components/radio/radio_button.spec.ts new file mode 100644 index 000000000000..b672086215c1 --- /dev/null +++ b/src/components/radio/radio_button.spec.ts @@ -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, ` + + + `) + .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, '') + .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, ` + + + `) + .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, ` + + + `) + .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, '') + .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, '') + .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, '') + .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, ` + + + + `) + .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, ` + + + + `) + .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, ` + + + + `) + .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 {} diff --git a/src/components/radio/radio_button.ts b/src/components/radio/radio_button.ts new file mode 100644 index 000000000000..0175bcf83a6a --- /dev/null +++ b/src/components/radio/radio_button.ts @@ -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 = new EventEmitter(); + + /** Child radio buttons. */ + @ContentChildren(forwardRef(() => MdRadioButton)) + private _radios: QueryList = 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 = 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; + } + } +} diff --git a/src/components/radio/radio_dispatcher.ts b/src/components/radio/radio_dispatcher.ts new file mode 100644 index 000000000000..f142b9c6b797 --- /dev/null +++ b/src/components/radio/radio_dispatcher.ts @@ -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); + } +} diff --git a/src/demo-app/demo-app.html b/src/demo-app/demo-app.html index d44ada6fd5ee..e1ad0b6cbacd 100644 --- a/src/demo-app/demo-app.html +++ b/src/demo-app/demo-app.html @@ -8,6 +8,7 @@

Angular Material2 Demos

  • Portal demo
  • Checkbox demo
  • Toolbar demo
  • +
  • Radio demo
  • + + + Option 1 + Option 2 + Option 3 + + diff --git a/src/demo-app/radio/radio-demo.scss b/src/demo-app/radio/radio-demo.scss new file mode 100644 index 000000000000..fd85f1baa3b4 --- /dev/null +++ b/src/demo-app/radio/radio-demo.scss @@ -0,0 +1,10 @@ +.demo-button { + margin: 8px; + text-transform: uppercase; +} + +.demo-section { + background-color: #f7f7f7; + margin: 8px; + padding: 16px; +} \ No newline at end of file diff --git a/src/demo-app/radio/radio-demo.ts b/src/demo-app/radio/radio-demo.ts new file mode 100644 index 000000000000..5e966b80dcec --- /dev/null +++ b/src/demo-app/radio/radio-demo.ts @@ -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; +}