feat(steps): add types

This commit is contained in:
Joren Broekema 2020-09-30 17:44:35 +02:00 committed by Thomas Allmer
parent 66c5f2cd36
commit 7d4b6d32db
6 changed files with 176 additions and 94 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/steps': minor
---
Add types for steps package.

View file

@ -0,0 +1,5 @@
---
'@lion/steps': minor
---
Add types for steps package.

View file

@ -1,5 +1,9 @@
import { css, html, LitElement } from '@lion/core';
/**
* @typedef {import('./LionSteps').LionSteps} LionSteps
*/
/**
* `LionStep` is one of many in a LionSteps Controller
*
@ -8,24 +12,6 @@ import { css, html, LitElement } from '@lion/core';
*/
export class LionStep extends LitElement {
static get properties() {
/**
* Fired when the step is entered.
*
* @event enter
*/
/**
* Fired when the step is left.
*
* @event left
*/
/**
* Fired when the step is skipped.
*
* @event skipped
*/
return {
/**
* Step status, one of: "untouched", "entered", "left", "skipped".
@ -39,13 +25,14 @@ export class LionStep extends LitElement {
* Takes lion-steps data as a first argument `myConditionFunc(data)`.
*/
condition: {
type: Function,
attribute: false,
},
/**
* Allows to invert condition function result.
*/
invertCondition: {
type: Boolean,
reflect: true,
attribute: 'invert-condition',
},
/**
@ -55,6 +42,7 @@ export class LionStep extends LitElement {
*/
forwardOnly: {
type: Boolean,
reflect: true,
attribute: 'forward-only',
},
/**
@ -63,6 +51,7 @@ export class LionStep extends LitElement {
*/
initialStep: {
type: Boolean,
reflect: true,
attribute: 'initial-step',
},
};
@ -71,7 +60,8 @@ export class LionStep extends LitElement {
constructor() {
super();
this.status = 'untouched';
this.condition = () => true;
// eslint-disable-next-line no-unused-vars
this.condition = /** @param {Object} [data] */ data => true;
this.invertCondition = false;
this.forwardOnly = false;
this.initialStep = false;
@ -99,9 +89,10 @@ export class LionStep extends LitElement {
return html`<slot></slot>`;
}
firstUpdated() {
super.firstUpdated();
this.controller = this.parentNode;
/** @param {import('lit-element').PropertyValues } changedProperties */
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.controller = /** @type {LionSteps} */ (this.parentNode);
}
enter() {
@ -119,6 +110,7 @@ export class LionStep extends LitElement {
this.dispatchEvent(new CustomEvent('skip', { bubbles: true, composed: true }));
}
/** @param {Object} [data] */
passesCondition(data) {
const result = this.condition(data);
return this.invertCondition ? !result : result;

View file

@ -1,5 +1,9 @@
import { css, html, LitElement } from '@lion/core';
/**
* @typedef {import('./LionStep.js').LionStep} LionStep
*/
/**
* `LionSteps` is a controller for a multi step system.
*
@ -7,6 +11,20 @@ import { css, html, LitElement } from '@lion/core';
* @extends {LitElement}
*/
export class LionSteps extends LitElement {
static get styles() {
return [
css`
:host {
display: block;
}
:host([hidden]) {
display: none;
}
`,
];
}
static get properties() {
/**
* Fired when a transition between steps happens.
@ -31,43 +49,19 @@ export class LionSteps extends LitElement {
};
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('current')) {
this._onCurrentChanged(
{ current: this.current },
{ current: changedProperties.get('current') },
);
}
}
constructor() {
super();
/** @type {{[key: string]: ?}} */
this.data = {};
this._internalCurrentSync = true; // necessary for preventing side effects on initialization
/** @type {number} */
this.current = 0;
this._max = 0;
}
static get styles() {
return [
css`
:host {
display: block;
}
:host([hidden]) {
display: none;
}
`,
];
}
render() {
return html`<slot></slot>`;
}
firstUpdated() {
super.firstUpdated();
/** @param {import('lit-element').PropertyValues } changedProperties */
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this._max = this.steps.length - 1;
let hasInitial = false;
@ -82,6 +76,21 @@ export class LionSteps extends LitElement {
}
}
/** @param {import('lit-element').PropertyValues } changedProperties */
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('current')) {
this._onCurrentChanged(
{ current: this.current },
{ current: /** @type {number} */ (changedProperties.get('current')) },
);
}
}
render() {
return html`<slot></slot>`;
}
next() {
this._goTo(this.current + 1, this.current);
}
@ -91,10 +100,18 @@ export class LionSteps extends LitElement {
}
get steps() {
const defaultSlot = this.shadowRoot.querySelector('slot:not([name])');
return defaultSlot.assignedNodes().filter(node => node.nodeType === Node.ELEMENT_NODE);
const defaultSlot = /** @type {HTMLSlotElement} */ (this.shadowRoot?.querySelector(
'slot:not([name])',
));
return /** @type {LionStep[]} */ (defaultSlot.assignedNodes()).filter(
node => node.nodeType === Node.ELEMENT_NODE,
);
}
/**
* @param {number} newCurrent
* @param {number} oldCurrent
*/
_goTo(newCurrent, oldCurrent) {
if (newCurrent < 0 || newCurrent > this._max) {
throw new Error(`There is no step at index ${newCurrent}.`);
@ -119,6 +136,10 @@ export class LionSteps extends LitElement {
}
}
/**
* @param {number} newCurrent
* @param {number} oldCurrent
*/
_changeStep(newCurrent, oldCurrent) {
const oldStepElement = this.steps[oldCurrent];
const newStepElement = this.steps[newCurrent];
@ -137,6 +158,10 @@ export class LionSteps extends LitElement {
this._dispatchTransitionEvent(fromStep, toStep);
}
/**
* @param {{number: number, element: LionStep}} fromStep
* @param {{number: number, element: LionStep}} toStep
*/
_dispatchTransitionEvent(fromStep, toStep) {
this.dispatchEvent(
new CustomEvent('transition', {
@ -147,6 +172,10 @@ export class LionSteps extends LitElement {
);
}
/**
* @param {{current: number}} newValues
* @param {{current: number}} oldValues
*/
_onCurrentChanged(newValues, oldValues) {
if (this._internalCurrentSync) {
this._internalCurrentSync = false;

View file

@ -2,6 +2,10 @@ import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import '../lion-step.js';
/**
* @typedef {import('../src/LionStep').LionStep} LionStep
*/
describe('lion-step', () => {
it('has a condition which allows it to become active (condition is true by default)', async () => {
const el = await fixture(html`
@ -9,8 +13,8 @@ describe('lion-step', () => {
<lion-step>Step 1</lion-step>
</fake-lion-steps>
`);
expect(el.children[0].condition()).to.equal(true);
expect(el.children[0].passesCondition()).to.equal(true);
expect(/** @type {LionStep} */ (el.children[0]).condition()).to.equal(true);
expect(/** @type {LionStep} */ (el.children[0]).passesCondition()).to.equal(true);
});
it('does not invert condition by default', async () => {
@ -19,8 +23,8 @@ describe('lion-step', () => {
<lion-step>Step 1</lion-step>
</fake-lion-steps>
`);
expect(el.children[0].invertCondition).to.equal(false);
expect(el.children[0].passesCondition()).to.equal(true);
expect(/** @type {LionStep} */ (el.children[0]).invertCondition).to.equal(false);
expect(/** @type {LionStep} */ (el.children[0]).passesCondition()).to.equal(true);
});
it('can invert its condition', async () => {
@ -29,10 +33,10 @@ describe('lion-step', () => {
<lion-step>Step 1</lion-step>
</fake-lion-steps>
`);
el.children[0].condition = () => true;
el.children[0].invertCondition = true;
expect(el.children[0].condition()).to.equal(true);
expect(el.children[0].passesCondition()).to.equal(false);
/** @type {LionStep} */ (el.children[0]).condition = () => true;
/** @type {LionStep} */ (el.children[0]).invertCondition = true;
expect(/** @type {LionStep} */ (el.children[0]).condition()).to.equal(true);
expect(/** @type {LionStep} */ (el.children[0]).passesCondition()).to.equal(false);
});
it('has "untouched" status by default', async () => {
@ -41,7 +45,7 @@ describe('lion-step', () => {
<lion-step>Step 1</lion-step>
</fake-lion-steps>
`);
expect(el.children[0].status).to.equal('untouched');
expect(/** @type {LionStep} */ (el.children[0]).status).to.equal('untouched');
});
it('is hidden when attribute hidden is true', async () => {
@ -61,7 +65,7 @@ describe('lion-step', () => {
<lion-step>Step 1</lion-step>
</fake-lion-steps>
`);
expect(el.children[0].controller).to.equal(el);
expect(/** @type {LionStep} */ (el.children[0]).controller).to.equal(el);
});
describe('navigation', () => {
@ -71,9 +75,9 @@ describe('lion-step', () => {
<lion-step>Step 1</lion-step>
</fake-lion-steps>
`);
setTimeout(() => el.children[0].enter(), 0);
setTimeout(() => /** @type {LionStep} */ (el.children[0]).enter(), 0);
await oneEvent(el.children[0], 'enter');
expect(el.children[0].status).to.equal('entered');
expect(/** @type {LionStep} */ (el.children[0]).status).to.equal('entered');
});
it('can be left', async () => {
@ -82,9 +86,9 @@ describe('lion-step', () => {
<lion-step>Step 1</lion-step>
</fake-lion-steps>
`);
setTimeout(() => el.children[0].leave(), 0);
setTimeout(() => /** @type {LionStep} */ (el.children[0]).leave(), 0);
await oneEvent(el.children[0], 'leave');
expect(el.children[0].status).to.equal('left');
expect(/** @type {LionStep} */ (el.children[0]).status).to.equal('left');
});
it('can be skipped', async () => {
@ -93,9 +97,9 @@ describe('lion-step', () => {
<lion-step>Step 1</lion-step>
</fake-lion-steps>
`);
setTimeout(() => el.children[0].skip(), 0);
setTimeout(() => /** @type {LionStep} */ (el.children[0]).skip(), 0);
await oneEvent(el.children[0], 'skip');
expect(el.children[0].status).to.equal('skipped');
expect(/** @type {LionStep} */ (el.children[0]).status).to.equal('skipped');
});
});
});

View file

@ -1,22 +1,40 @@
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { expect, fixture as _fixture, html, oneEvent } from '@open-wc/testing';
import sinon from 'sinon';
import '../lion-step.js';
import '../lion-steps.js';
/**
* @typedef {import('../src/LionSteps').LionSteps} LionSteps
* @typedef {import('../src/LionStep').LionStep} LionStep
* @typedef {import('lit-html').TemplateResult} TemplateResult
* @typedef {{[key: string]: ?}} UnknownData
*/
const fixture = /** @type {(arg: TemplateResult) => Promise<LionSteps>} */ (_fixture);
/**
*
* @param {LionSteps} steps
* @param {Object} expected
* @param {string} expected.transitions
* @param {string} expected.statuses
*/
async function checkWorkflow(steps, expected) {
return new Promise(resolve => {
/** @type {number[]} */
const transitions = [];
steps.addEventListener('transition', event => {
const _event = /** @type {CustomEvent} */ (event);
if (!transitions.length) {
transitions.push(event.detail.fromStep.number);
transitions.push(_event.detail.fromStep.number);
}
transitions.push(event.detail.toStep.number);
transitions.push(_event.detail.toStep.number);
});
setTimeout(() => {
// allow time for other transitions to happen if any
const transitionsString = transitions.join(' => ');
expect(transitionsString).to.equal(expected.transitions, 'transition flow is different');
const statusesString = Array.from(steps.children)
const statusesString = /** @type {LionStep[]} */ (Array.from(steps.children))
.map(step => step.status)
.join(' ');
expect(statusesString).to.equal(expected.statuses, 'steps statuses are different');
@ -60,7 +78,7 @@ describe('lion-steps', () => {
</lion-steps>
`);
expect(el.current).to.equal(0);
expect(el.children[0].status).to.equal('entered');
expect(/** @type {LionStep} */ (el.children[0]).status).to.equal('entered');
});
});
@ -133,9 +151,13 @@ describe('lion-steps', () => {
const el = await fixture(html`
<lion-steps .data=${{ age: 25 }}>
<lion-step initial-step>Step 0</lion-step>
<lion-step .condition=${data => data.age < 18}>Step 1</lion-step>
<lion-step .condition=${/** @param {UnknownData} data */ data => data.age < 18}
>Step 1</lion-step
>
<lion-step>Step 2</lion-step>
<lion-step .condition=${data => data.age < 22}>Step 3</lion-step>
<lion-step .condition=${/** @param {UnknownData} data */ data => data.age < 22}
>Step 3</lion-step
>
<lion-step>Step 4</lion-step>
</lion-steps>
`);
@ -170,8 +192,8 @@ describe('lion-steps', () => {
describe('events', () => {
it('will fire lion-step @leave event before changing .current', async () => {
let currentInLeaveEvent;
const onLeave = ev => {
currentInLeaveEvent = ev.target.controller.current;
const onLeave = /** @param {Event} ev */ ev => {
currentInLeaveEvent = /** @type {LionStep} */ (ev.target).controller?.current;
};
const el = await fixture(html`
<lion-steps>
@ -186,8 +208,8 @@ describe('lion-steps', () => {
it('will fire lion-step @enter event after changing .current', async () => {
let currentInEnterEvent;
const onEnter = ev => {
currentInEnterEvent = ev.target.controller.current;
const onEnter = /** @param {Event} ev */ ev => {
currentInEnterEvent = /** @type {LionStep} */ (ev.target).controller?.current;
};
const el = await fixture(html`
<lion-steps>
@ -245,9 +267,16 @@ describe('lion-steps', () => {
const el = await fixture(html`
<lion-steps .data=${{ age: 25 }}>
<lion-step initial-step>Step 0</lion-step>
<lion-step .condition=${data => data.age < 18}>Step 1</lion-step>
<lion-step .condition=${data => data.age >= 18 && data.age < 21}>Step 2</lion-step>
<lion-step .condition=${data => data.age >= 21}>Step 3</lion-step>
<lion-step .condition=${/** @param {UnknownData} data */ data => data.age < 18}
>Step 1</lion-step
>
<lion-step
.condition=${/** @param {UnknownData} data */ data => data.age >= 18 && data.age < 21}
>Step 2</lion-step
>
<lion-step .condition=${/** @param {UnknownData} data */ data => data.age >= 21}
>Step 3</lion-step
>
<lion-step>Step 4</lion-step>
</lion-steps>
`);
@ -264,9 +293,16 @@ describe('lion-steps', () => {
const el = await fixture(html`
<lion-steps .data=${{ age: 19 }}>
<lion-step initial-step>Step 0</lion-step>
<lion-step .condition=${data => data.age < 18}>Step 1</lion-step>
<lion-step .condition=${data => data.age >= 18 && data.age < 21}>Step 2</lion-step>
<lion-step .condition=${data => data.age >= 21}>Step 3</lion-step>
<lion-step .condition=${/** @param {UnknownData} data */ data => data.age < 18}
>Step 1</lion-step
>
<lion-step
.condition=${/** @param {UnknownData} data */ data => data.age >= 18 && data.age < 21}
>Step 2</lion-step
>
<lion-step .condition=${/** @param {UnknownData} data */ data => data.age >= 21}
>Step 3</lion-step
>
<lion-step>Step 4</lion-step>
</lion-steps>
`);
@ -285,9 +321,16 @@ describe('lion-steps', () => {
const el = await fixture(html`
<lion-steps .data=${{ age: 15 }}>
<lion-step initial-step>Step 0</lion-step>
<lion-step .condition=${data => data.age < 18}>Step 1</lion-step>
<lion-step .condition=${data => data.age >= 18 && data.age < 21}>Step 2</lion-step>
<lion-step .condition=${data => data.age >= 21}>Step 3</lion-step>
<lion-step .condition=${/** @param {UnknownData} data */ data => data.age < 18}
>Step 1</lion-step
>
<lion-step
.condition=${/** @param {UnknownData} data */ data => data.age >= 18 && data.age < 21}
>Step 2</lion-step
>
<lion-step .condition=${/** @param {UnknownData} data */ data => data.age >= 21}
>Step 3</lion-step
>
<lion-step>Step 4</lion-step>
</lion-steps>
`);
@ -309,7 +352,11 @@ describe('lion-steps', () => {
const el = await fixture(html`
<lion-steps .data=${{}}>
<lion-step initial-step>Step 0</lion-step>
<lion-step .condition=${data => data.age < 18} invert-condition>Step 1</lion-step>
<lion-step
.condition=${/** @param {UnknownData} data */ data => data.age < 18}
invert-condition
>Step 1</lion-step
>
<lion-step>Step 2</lion-step>
</lion-steps>
`);
@ -332,7 +379,7 @@ describe('lion-steps', () => {
});
it('behaves like "if/else" in case both condition and inverted condition are present', async () => {
const condition = data => data.age < 18;
const condition = /** @param {UnknownData} data */ data => data.age < 18;
const el = await fixture(html`
<lion-steps .data=${{}}>
<lion-step initial-step>Step 0</lion-step>
@ -411,7 +458,7 @@ describe('lion-steps', () => {
el.next();
el.previous();
expect(el.children[1].status).to.equal('left');
expect(/** @type {LionStep} */ (el.children[1]).status).to.equal('left');
});
});
});