Skip to content

Commit

Permalink
✨ Implement <RadioGroup /> component (GavinJoyce#143)
Browse files Browse the repository at this point in the history
* Add supplementary test assertions in preparation for radio-group tests

* Create radio-group-test.js

* Create <RadioGroup /> component

* Add radio group demo to dummy app

* Refactor component to reflect app changes

* convert did-insert hooks to modifiers

* 📦 Bump [email protected]

Co-authored-by: Aoife Hannigan <[email protected]>
Co-authored-by: David McNamara <[email protected]>
  • Loading branch information
3 people authored Jul 12, 2022
1 parent 5df826c commit 95b1f02
Show file tree
Hide file tree
Showing 16 changed files with 1,443 additions and 3 deletions.
32 changes: 32 additions & 0 deletions ember-headlessui/addon/components/radio-group.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<input type='hidden' value={{this.selectedValue}} />
{{! template-lint-disable no-positive-tabindex}}
<div
data-test-radio-group
id={{this.radioGroupGuid}}
role='radiogroup'
aria-labelledby={{this.radioGroupLabel}}
aria-disabled={{@disabled}}
{{this.tabIndex}}
>
{{yield
(hash
activeValue=this.activeValue
Label=(component 'radio-group/-label' guid=this.guid)
Option=(component
'radio-group/-option'
activeValue=this.activeValue
disabled=@disabled
handleClick=this.handleClick
handleKeyup=this.handleKeyup
onChange=this.onChange
options=this.options
radioGroupGuid=this.radioGroupGuid
registerOption=this.registerOption
unregisterOption=this.unregisterOption
selectedValue=this.selectedValue
value=@value
)
)
}}
</div>
{{! template-lint-disable no-positive-tabindex}}
192 changes: 192 additions & 0 deletions ember-headlessui/addon/components/radio-group.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';

import { Keys } from 'ember-headlessui/utils/keyboard';
import { modifier } from 'ember-modifier';

export default class RadioGroupComponent extends Component {
guid = guidFor(this);
radioGroupGuid = `headlessui-radiogroup-${this.guid}`;
radioGroupLabel = `headlessui-label-${this.guid}`;

@tracked activeOption; //-option component
@tracked activeValue = this.args.value; //string
@tracked selectedValue; //string
@tracked options = [];

tabIndex = modifier(() => {
this.setTabIndex();

return () => {
this.setTabIndex();
};
});

get activeOptionIndex() {
return this.options.indexOf(this.activeOption);
}

@action
goToFirstOption() {
let firstOption = this.options.find((option) => option);
this._setActiveOption(firstOption);
}

@action
goToLastOption() {
let lastOption = this.options
.slice()
.reverse()
.find((option) => option);
this._setActiveOption(lastOption);
}

@action
goToNextOption() {
let nextOption = this.options.find((option, index) => {
if (index <= this.activeOptionIndex) {
return false;
}
return option;
});
this._setActiveOption(nextOption);
}

@action
goToPreviousOption() {
let previousOption = this.options
.slice()
.reverse()
.find((option, index) => {
if (
this.activeOptionIndex !== -1 &&
this.options.length - index - 1 >= this.activeOptionIndex
) {
return false;
}
return option;
});
this._setActiveOption(previousOption);
}

@action
handleClick(value) {
this.activeValue = value;
this.selectedValue = value;
}

@action
handleKeyup(event) {
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault();
return this.onKeySelect(event);
case Keys.ArrowDown:
case Keys.ArrowRight:
event.preventDefault();

if (
document.activeElement ===
this.options[this.options.length - 1].element
)
return this.goToFirstOption();

return this.goToNextOption();
case Keys.ArrowUp:
case Keys.ArrowLeft:
event.preventDefault();

if (document.activeElement === this.options[0].element)
return this.goToLastOption();

return this.goToPreviousOption();
default:
break;
}
}

@action
onChange(option) {
if (this.args.onChange) {
this.args.onChange(option);
}
}

@action
onKeySelect(event) {
if (this.args.disabled) return;
let hasOptionNotBeenSelected =
this.selectedValue !== event.target.ariaLabel;

if (this.args.onChange && hasOptionNotBeenSelected) {
this.selectedValue = event.target.ariaLabel;
this.onChange(this.selectedValue);
}
}

@action
async registerOption(option) {
let { options } = this;

options.push(option);
await Promise.resolve(() => (this.options = options));
}

@action
async unregisterOption(option) {
let { options } = this;

let index = options.indexOf(option);
options.splice(index, 1);
await Promise.resolve(() => (this.options = options));
}

@action
setTabIndex() {
this.options.forEach((option) => {
let isFirstOption = this.options[0]?.id === option.id;

let containsCheckedOption = this.options.filter((option) => {
return (
option.id &&
option.value === this.activeValue &&
option.value !== null
);
});

let checked = this.activeValue === option.value;

if (this.disabled) {
option.element.tabIndex = -1;
return;
}

if (checked) {
this.activeOption = option;
option.element.tabIndex = 0;
return;
}

if (!containsCheckedOption.length && isFirstOption) {
this.activeOption = option;
option.element.tabIndex = 0;
return;
}

option.element.tabIndex = -1;
});
}

_setActiveOption(option) {
if (option) {
option.element.focus();
this.activeOption = option;
this.activeValue = option.value;
this.setTabIndex();
this.onChange(option.value);
}
}
}
1 change: 1 addition & 0 deletions ember-headlessui/addon/components/radio-group/-label.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<label id='headlessui-label-{{@guid}}' ...attributes>{{yield}}</label>
14 changes: 14 additions & 0 deletions ember-headlessui/addon/components/radio-group/-option.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<div
id={{this.id}}
ariaLabel={{@value}}
aria-labelledby={{this.label}}
aria-disabled={{@disabled}}
role='radio'
aria-checked={{eq @activeValue @value}}
{{on 'click' (fn this.onClick @value)}}
{{on 'keyup' @handleKeyup}}
{{this.registration}}
...attributes
>
{{yield}}
</div>
48 changes: 48 additions & 0 deletions ember-headlessui/addon/components/radio-group/-option.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';

import { modifier } from 'ember-modifier';

export default class Option extends Component {
guid = guidFor(this);
element;
id = `headlessui-radiogroup-option-${this.guid}`;
label = `headlessui-label-${this.guid}`;
value = this.args.value;

constructor() {
super(...arguments);

let { radioGroupGuid } = this.args;

if (radioGroupGuid === undefined) {
throw new Error(
'<RadioGroup::-Option /> is missing a parent <RadioGroup /> component.'
);
}
}

registration = modifier((element) => {
this.element = element;
this.args.registerOption(this);

return () => {
this.element = null;
this.args.unregisterOption(this);
};
});

@action
onClick(value) {
if (this.args.disabled) return;

let hasOptionNotBeenSelected = this.args.selectedValue !== value;

if (this.args.onChange && hasOptionNotBeenSelected) {
this.args.onChange(value);
}

this.args.handleClick(value);
}
}
1 change: 1 addition & 0 deletions ember-headlessui/app/components/radio-group.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'ember-headlessui/components/radio-group';
1 change: 1 addition & 0 deletions ember-headlessui/app/components/radio-group/-label.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'ember-headlessui/components/radio-group/-label';
1 change: 1 addition & 0 deletions ember-headlessui/app/components/radio-group/-option.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'ember-headlessui/components/radio-group/-option';
2 changes: 1 addition & 1 deletion ember-headlessui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"prettier": "^2.3.2",
"qunit": "^2.14.1",
"qunit-dom": "^1.6.0",
"release-it": "^14.8.0",
"release-it": "^15.1.1",
"release-it-lerna-changelog": "^3.1.0",
"typescript": "^4.7.3",
"webpack": "^5.64.1"
Expand Down
4 changes: 4 additions & 0 deletions test-app/app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,8 @@ Router.map(function () {
this.route('dialog-slide-over');
this.route('dialog-nested');
});

this.route('radio-group', function () {
this.route('radio-group-basic');
});
});
14 changes: 13 additions & 1 deletion test-app/app/templates/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@
</li>
</ul>
</li>
<li>
<h3 class='text-xl'>
Radio Group
</h3>
<ul>
<li>
<LinkTo @route='radio-group.radio-group-basic'>
Radio Group (basic)
</LinkTo>
</li>
</ul>
</li>
<li>
<h3 class='text-xl'>
Switch
Expand Down Expand Up @@ -95,4 +107,4 @@
</li>
</ul>
</div>
</div>
</div>
Loading

0 comments on commit 95b1f02

Please sign in to comment.