Skip to content

Commit

Permalink
Tab Component (#307)
Browse files Browse the repository at this point in the history
  • Loading branch information
yomed committed Aug 7, 2018
1 parent b1cca0d commit 4a485e1
Show file tree
Hide file tree
Showing 17 changed files with 556 additions and 10 deletions.
1 change: 0 additions & 1 deletion .eslintrc
Expand Up @@ -13,7 +13,6 @@
"mocha/no-exclusive-tests": 2,
"mocha/no-identical-title": 2,
"mocha/no-nested-tests": 2,
"mocha/no-return-and-callback": 2,
"mocha/no-sibling-hooks": 2,
"compat/compat": 2
}
Expand Down
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -40,6 +40,7 @@ Marko v4 requires [Marko Widgets v7](https://github.com/marko-js/marko-widgets/t
* [`ebay-notice`](https://github.com/eBay/ebayui-core/tree/master/src/components/ebay-notice)
* [`ebay-pagination`](https://github.com/eBay/ebayui-core/tree/master/src/components/ebay-pagination)
* [`ebay-select`](https://github.com/eBay/ebayui-core/tree/master/src/components/ebay-select)
* [`ebay-tab`](https://github.com/eBay/ebayui-core/tree/master/src/components/ebay-tab)
* [`ebay-textbox`](https://github.com/eBay/ebayui-core/tree/master/src/components/ebay-textbox)

## Getting Started
Expand Down
1 change: 1 addition & 0 deletions demo/browser.json
Expand Up @@ -17,6 +17,7 @@
"../src/components/ebay-pagination",
"../src/components/ebay-select",
"../src/components/ebay-radio",
"../src/components/ebay-tab",
"../src/components/ebay-textbox",
{
"if-flag": "skin-ds6",
Expand Down
22 changes: 22 additions & 0 deletions marko.json
Expand Up @@ -194,6 +194,28 @@
}
},
"<ebay-select-option>": {},
"<ebay-tab>": {
"renderer": "./src/components/ebay-tab/index.js",
"transformer": "./src/common/transformer/index.js",
"@*": "expression",
"@html-attributes": "expression",
"@class": "string",
"@index": "string",
"@fake": "boolean",
"@headings <heading>[]": {
"@*": "expression",
"@html-attributes": "expression",
"@class": "string",
"@href": "string"
},
"@panels <panel>[]": {
"@*": "expression",
"@html-attributes": "expression",
"@class": "string"
}
},
"<ebay-tab-heading>": {},
"<ebay-tab-panel>": {},
"<ebay-textbox>": {
"renderer": "./src/components/ebay-textbox/index.js",
"@*": "expression",
Expand Down
8 changes: 6 additions & 2 deletions src/common/property-observer/index.js
Expand Up @@ -5,15 +5,19 @@ const _set = require('lodash.set');
* For each attribute, define getter and setter on root DOM element of the widget
* @param {Object} widget
* @param {Array} attributes
* @param {Function} callback
* @param {Boolean} skipSetState: useful for handling setState in your component, rather than here
*/
function observeRoot(widget, attributes, callback) {
function observeRoot(widget, attributes, callback, skipSetState) {
attributes.forEach(attribute => {
Object.defineProperty(widget.el, attribute, {
get() {
return widget.state[attribute];
},
set(value) {
widget.setState(attribute, value);
if (!skipSetState) {
widget.setState(attribute, value);
}
if (callback) {
callback(value);
}
Expand Down
15 changes: 8 additions & 7 deletions src/common/test-utils/server.js
Expand Up @@ -29,32 +29,33 @@ function getCheerio(output) {
* @param {Object} input: additional input to use with test utils
* @param {String} arrayKey: if provided, assign input as a single-entry array (for marko nested tags)
* @param {String} baseInput: if provided, use as base for additional input
* @param {String} parentInput: use to modify base input of parent, rather than that of arrayKey
*/
function setupInput(input, arrayKey, baseInput) {
function setupInput(input, arrayKey, baseInput, parentInput = {}) {
let newInput = baseInput ? Object.assign(baseInput, input) : input;

if (arrayKey) {
newInput = { [arrayKey]: [newInput] };
newInput = Object.assign(parentInput, { [arrayKey]: [newInput] });
}

return newInput;
}

function testCustomClass(context, selector, arrayKey, isPassThrough, baseInput) {
function testCustomClass(context, selector, arrayKey, isPassThrough, baseInput, parentInput) {
let input;
if (isPassThrough) {
input = setupInput({ '*': { class: 'class1 class2' } }, arrayKey, baseInput);
input = setupInput({ '*': { class: 'class1 class2' } }, arrayKey, baseInput, parentInput);
} else {
input = setupInput({ class: 'class1 class2' }, arrayKey, baseInput);
input = setupInput({ class: 'class1 class2' }, arrayKey, baseInput, parentInput);
}
const $ = getCheerio(context.render(input));
expect($(`${selector}.class1.class2`).length).to.equal(1);
}

function testHtmlAttributes(context, selector, arrayKey, baseInput) {
function testHtmlAttributes(context, selector, arrayKey, baseInput, parentInput) {
// check that each method is correctly supported
['*', 'htmlAttributes'].forEach(key => {
const input = setupInput({ [key]: { 'aria-role': 'link' } }, arrayKey, baseInput);
const input = setupInput({ [key]: { 'aria-role': 'link' } }, arrayKey, baseInput, parentInput);
const $ = getCheerio(context.render(input));
expect($(`${selector}[aria-role=link]`).length).to.equal(1);
});
Expand Down
49 changes: 49 additions & 0 deletions src/components/ebay-tab/README.md
@@ -0,0 +1,49 @@
# ebay-tab

## ebay-tab Usage

```marko
<ebay-tab>
<ebay-tab-heading>Tab 1</ebay-tab-heading>
<ebay-tab-heading>Tab 2</ebay-tab-heading>
<ebay-tab-heading>Tab 3</ebay-tab-heading>
<ebay-tab-panel>Panel 1</ebay-tab-panel>
<ebay-tab-panel>Panel 2</ebay-tab-panel>
<ebay-tab-panel>Panel 3</ebay-tab-panel>
</ebay-tab>
```

## ebay-tab Attributes

Name | Type | Stateful | Description
--- | --- | --- | ---
`index` | String | Yes | 0-based index of selected tab heading and panel
`fake` | Boolean | No | Whether to use link behavior for tab headings

## ebay-tab Events

Event | Data | Description
--- | --- | ---
`tab-select` | `{ index }` |

## ebay-tab-heading Tag

### ebay-tab-heading Usage

```marko
<ebay-tab-heading>Tab 1</ebay-tab-heading>
```

## ebay-tab-heading Attributes

Name | Type | Stateful | Description
--- | --- | --- | ---
`href` | String | No | For use with `fake` tab component

## ebay-tab-panel Tag

### ebay-tab-panel Usage

```marko
<ebay-tab-panel>Panel 1</ebay-tab-panel>
```
10 changes: 10 additions & 0 deletions src/components/ebay-tab/browser.json
@@ -0,0 +1,10 @@
{
"dependencies": [
{
"if-not-flag": "ebayui-no-skin",
"path": "@ebay/skin/tab"
},
"require: marko-widgets",
"require: ./index.js"
]
}
17 changes: 17 additions & 0 deletions src/components/ebay-tab/examples/01-basic/template.marko
@@ -0,0 +1,17 @@
<ebay-tab>
<ebay-tab-heading>Tab 1</ebay-tab-heading>
<ebay-tab-heading>Tab 2</ebay-tab-heading>
<ebay-tab-heading>Tab 3</ebay-tab-heading>
<ebay-tab-panel>
<h3>Panel 1</h3>
<p>1. Lorem ipsum dolor sit amet</p>
</ebay-tab-panel>
<ebay-tab-panel>
<h3>Panel 2</h3>
<p>2. Lorem ipsum dolor sit amet</p>
</ebay-tab-panel>
<ebay-tab-panel>
<h3>Panel 3</h3>
<p>3. Lorem ipsum dolor sit amet</p>
</ebay-tab-panel>
</ebay-tab>
17 changes: 17 additions & 0 deletions src/components/ebay-tab/examples/02-starting-index/template.marko
@@ -0,0 +1,17 @@
<ebay-tab index="2">
<ebay-tab-heading>Tab 1</ebay-tab-heading>
<ebay-tab-heading>Tab 2</ebay-tab-heading>
<ebay-tab-heading>Tab 3</ebay-tab-heading>
<ebay-tab-panel>
<h3>Panel 1</h3>
<p>1. Lorem ipsum dolor sit amet</p>
</ebay-tab-panel>
<ebay-tab-panel>
<h3>Panel 2</h3>
<p>2. Lorem ipsum dolor sit amet</p>
</ebay-tab-panel>
<ebay-tab-panel>
<h3>Panel 3</h3>
<p>3. Lorem ipsum dolor sit amet</p>
</ebay-tab-panel>
</ebay-tab>
8 changes: 8 additions & 0 deletions src/components/ebay-tab/examples/03-fake/template.marko
@@ -0,0 +1,8 @@
<ebay-tab fake>
<ebay-tab-heading href="https://www.ebay.com/">Tab 1</ebay-tab-heading>
<ebay-tab-heading href="https://www.ebay.com/">Tab 2</ebay-tab-heading>
<ebay-tab-heading href="https://www.ebay.com/">Tab 3</ebay-tab-heading>
<ebay-tab-panel>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla ornare, quam at lacinia pretium, lacus urna luctus nisi, eget molestie massa tortor id lacus. Aenean ac fringilla lacus. Fusce vel dui ex. Vivamus luctus egestas nulla, non hendrerit purus luctus at. Maecenas vel diam enim. Pellentesque quam neque, porttitor tincidunt vestibulum at, dapibus sit amet tortor.</p>
</ebay-tab-panel>
</ebay-tab>
@@ -0,0 +1,8 @@
<ebay-tab fake index="1">
<ebay-tab-heading href="https://www.ebay.com/">Tab 1</ebay-tab-heading>
<ebay-tab-heading href="https://www.ebay.com/">Tab 2</ebay-tab-heading>
<ebay-tab-heading href="https://www.ebay.com/">Tab 3</ebay-tab-heading>
<ebay-tab-panel>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla ornare, quam at lacinia pretium, lacus urna luctus nisi, eget molestie massa tortor id lacus. Aenean ac fringilla lacus. Fusce vel dui ex. Vivamus luctus egestas nulla, non hendrerit purus luctus at. Maecenas vel diam enim. Pellentesque quam neque, porttitor tincidunt vestibulum at, dapibus sit amet tortor.</p>
</ebay-tab-panel>
</ebay-tab>
105 changes: 105 additions & 0 deletions src/components/ebay-tab/index.js
@@ -0,0 +1,105 @@
const markoWidgets = require('marko-widgets');
const rovingTabindex = require('makeup-roving-tabindex');
const emitAndFire = require('../../common/emit-and-fire');
const eventUtils = require('../../common/event-utils');
const processHtmlAttributes = require('../../common/html-attributes');
const observer = require('../../common/property-observer');
const template = require('./template.marko');

function getInitialState(input) {
const fake = Boolean(input.fake);
const index = parseInt(input.index) || 0;
const headings = (input.headings || []).map(heading => ({
renderBody: heading.renderBody,
classes: fake ? heading.class : [heading.class, 'tabs__item'],
href: heading.href,
htmlAttributes: processHtmlAttributes(heading)
}));
const panels = (input.panels || []).map(panel => ({
renderBody: panel.renderBody,
classes: [panel.class, prefix(fake, 'tabs__panel')],
htmlAttributes: processHtmlAttributes(panel)
}));

return {
index,
fake,
headings,
panels,
classes: [input.class, prefix(fake, 'tabs')],
htmlAttributes: processHtmlAttributes(input)
};
}

function getTemplateData(state) {
return state;
}

function init() {
this.headingsEl = this.getEl('headings');
if (!this.state.fake) {
rovingTabindex.createLinear(this.headingsEl, 'div', { index: 0, autoReset: 0 });
}
observer.observeRoot(this, ['index'], index => this.processStateChange(parseInt(index)), true);
}

/**
* Common processing of index change via both UI and API
* @param {Number} index
*/
function processStateChange(index) {
if (index >= 0 && index < this.state.headings.length && index !== this.state.index) {
this.setState('index', index);
emitAndFire(this, 'tab-select', { index });
}
}

/**
* Handle mouse click on heading
* @param {MouseEvent} e
*/
function handleHeadingClick(e) {
let headingEl = e.target;
const headingClass = prefix(this.state.fake, 'tabs__item');
while (!headingEl.classList.contains(headingClass)) {
headingEl = headingEl.parentNode;
}

this.processStateChange(getElementIndex(headingEl));
}

/**
* Get 0-based index of element within its parent
* @param {HTMLElement} headingEl
*/
function getElementIndex(headingEl) {
return Array.prototype.slice.call(headingEl.parentNode.children).indexOf(headingEl);
}

/**
* Handle accessibility for heading
* https://ebay.gitbooks.io/mindpatterns/content/disclosure/tabs.html
* @param {KeyboardEvent} e
*/
function handleHeadingKeydown(e) {
eventUtils.handleActionKeydown(e, () => this.handleHeadingClick(e));
}

/**
* Helper to prefix a class based on fake status
* @param {Boolean} fake
* @param {String} c
*/
function prefix(fake, c) {
return (fake ? 'fake-' : '') + c;
}

module.exports = markoWidgets.defineComponent({
template,
getInitialState,
getTemplateData,
init,
processStateChange,
handleHeadingClick,
handleHeadingKeydown
});
6 changes: 6 additions & 0 deletions src/components/ebay-tab/mock/index.js
@@ -0,0 +1,6 @@
const headings = [{}, {}, {}];
const panels = [{}, {}, {}];

const fakeHeadings = [{ href: '#' }, { href: '#' }, { href: '#' }];

module.exports = { headings, panels, fakeHeadings };

0 comments on commit 4a485e1

Please sign in to comment.