mirror of
https://github.com/twirl/The-API-Book.git
synced 2024-11-30 08:06:47 +02:00
Examples for 'Decomposing Components' chapter - first draft
This commit is contained in:
parent
c3dba5c1bc
commit
15ca4b3417
BIN
docs/assets/RobotoMono-Regular.ttf
Normal file
BIN
docs/assets/RobotoMono-Regular.ttf
Normal file
Binary file not shown.
51
docs/examples/01. Decomposing UI Components/README.md
Normal file
51
docs/examples/01. Decomposing UI Components/README.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Decomposing UI Components
|
||||
|
||||
This is the example illustrating complexities of decomposing a UI component into a series of subcomponents that would simultaneously allow to:
|
||||
* Redefining the appearance of each of the subcomponent
|
||||
* Introducing new business logic while keeping styling consistent
|
||||
* Inheriting the UX of the component while changing both UI and business logic.
|
||||
|
||||
The `src` folder contains a TypeScript code for the component and corresponding interfaces, and the `index.js` file contains the compiled JavaScript (check `tsconfig.json` for compiler settings).
|
||||
|
||||
The `index.html` page includes a living example for each of the discussed scenarios, with links pointing to external playgrounds to work through the code if needed. [View it in your browser](https://twirl.github.io/examples/01.%20Decomposing%20UI%20Components/index.html).
|
||||
|
||||
The following improvements to the code are left as an exercise for the reader:
|
||||
* Returning operation status from the `SearchBox.search` method:
|
||||
```
|
||||
public search(query: string): Promise<OperationResult>
|
||||
```
|
||||
|
||||
Where
|
||||
|
||||
```
|
||||
type OperationResult =
|
||||
| {
|
||||
status: OperationResultStatus.SUCCESS;
|
||||
results: ISearchResult[];
|
||||
}
|
||||
| { status: OperationResultStatus.INTERRUPTED }
|
||||
| {
|
||||
status: OperationResultStatus.FAIL;
|
||||
error: any;
|
||||
};
|
||||
|
||||
export enum OperationResultStatus {
|
||||
SUCCESS = 'success',
|
||||
FAIL = 'fail',
|
||||
INTERRUPTED = 'interrupted'
|
||||
}
|
||||
```
|
||||
|
||||
* Make offer list paginated (implying adding pagination parameters to `ICoffeeApi.search` request and response, and dynamically loading new items while scrolling the offer list)
|
||||
|
||||
* Make the input string and the search button a separate `ISeachBoxInput` component. Add an ability to cancel the ongoing request. Add a “skeleton” animation to indicate search results are being loading.
|
||||
|
||||
* Localize the component, making a locale and a dictionary a part of the `ISearchBox` options.
|
||||
|
||||
* Parametrize `context` parameter for `OfferListComponent` and `OfferPanelComponent`. Make it comprise only events needed by the component, so that `ISearchBoxComposer` would be implementing `IOfferListComponentContext` and `IOfferPanelComponentContext` interfaces.
|
||||
|
||||
* Make `options` mutable (expose an `optionChange` event and implement `Composers`'s reaction to relevant option changes)
|
||||
|
||||
* Parametrize markups of components, either by:
|
||||
* Incapsulating them in some `Layout` entities controlled through options. Create interfaces for each of the layouts. Create a `VisualComponent` base class for entities that have a layout and inherit `SearchBox`, `OfferListComponent` and `OfferPanelComponent` from it, or
|
||||
* Rewriting components as React / ReactNative / SwiftUI / Android View component or as a UI component for any other platform of your choice.
|
84
docs/examples/01. Decomposing UI Components/index.html
Normal file
84
docs/examples/01. Decomposing UI Components/index.html
Normal file
@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>
|
||||
“The API” book by Sergey Konstantinov. Examples to the “Decomposing
|
||||
UI Components” chapter.
|
||||
</title>
|
||||
<link rel="stylesheet" type="text/css" href="../fonts.css" />
|
||||
<style>
|
||||
body > ul {
|
||||
display: flex;
|
||||
}
|
||||
body > ul > li {
|
||||
list-style-type: node;
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
border: 1px solid gray;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
const dummySearchApi = {
|
||||
search: () => ({
|
||||
results: [
|
||||
{
|
||||
offer_id: '1',
|
||||
title: 'Cafe “Chamomile”',
|
||||
subtitle: '200m · 2 min',
|
||||
coffee_chain: {
|
||||
id: 'chamomile_chain',
|
||||
logo: './logo.png'
|
||||
},
|
||||
recipe: {
|
||||
id: 'lungo'
|
||||
},
|
||||
place: {
|
||||
location: [0, 0]
|
||||
},
|
||||
offer_details: {
|
||||
description: `It's our best Lungo.
|
||||
Smart SDK developers always choose Cafe “Chamomile” lungo!`
|
||||
},
|
||||
pricing: {
|
||||
price: '37',
|
||||
currency: 'USD',
|
||||
formatted_price: '$37'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
};
|
||||
|
||||
const examples = document.querySelectorAll('.example');
|
||||
for (const example of examples) {
|
||||
const preview = example.querySelector('.example-preview');
|
||||
const code = example.querySelector('.code').innerHTML;
|
||||
let Class;
|
||||
eval(`Class = ${code}`);
|
||||
const instance = Class.build(preview, dummySearchApi);
|
||||
instance.setSearchPhrase('lungo');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>
|
||||
Examples of Overriding Different Aspects of a Decomposed UI
|
||||
Component
|
||||
</h1>
|
||||
<ul>
|
||||
<li class="example">
|
||||
<h2>Example #1: A regular <code>SearchBox</code></h2>
|
||||
<h3>Preview:</h3>
|
||||
<div class="example-preview"></div>
|
||||
<h3>Source code:</h3>
|
||||
<pre class="code"></pre>
|
||||
</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,121 @@
|
||||
import { attrValue, html, raw } from './util/html';
|
||||
import {
|
||||
IOfferListComponent,
|
||||
IOfferListComponentOptions,
|
||||
IOfferListEvents
|
||||
} from './interfaces/IOfferListComponent';
|
||||
import { IDisposer, IEventEmitter } from './interfaces/common';
|
||||
import {
|
||||
IOfferPreview,
|
||||
ISearchBoxComposer
|
||||
} from './interfaces/ISearchBoxComposer';
|
||||
import { EventEmitter } from './util/EventEmitter';
|
||||
|
||||
export class OfferListComponent implements IOfferListComponent {
|
||||
public events: IEventEmitter<IOfferListEvents> = new EventEmitter();
|
||||
|
||||
protected listenerDisposers: IDisposer[] = [];
|
||||
|
||||
constructor(
|
||||
protected readonly context: ISearchBoxComposer,
|
||||
protected readonly container: HTMLElement,
|
||||
protected offerList: IOfferPreview[] | null,
|
||||
protected readonly options: IOfferListComponentOptions
|
||||
) {
|
||||
this.render();
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
protected onOfferListChange({ offerList }) {
|
||||
this.offerList = offerList;
|
||||
this.render();
|
||||
}
|
||||
|
||||
public getOfferList() {
|
||||
return this.offerList;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.teardownListeners();
|
||||
this.teardownDomListeners();
|
||||
this.offerList = null;
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
|
||||
public selectOffer(offerId: string) {
|
||||
this.events.emit('offerSelect', {
|
||||
offerId
|
||||
});
|
||||
}
|
||||
|
||||
protected render() {
|
||||
this.teardownDomListeners();
|
||||
this.container.innerHTML = html`<ul class="our-coffee-api-offer-list">
|
||||
${this.offerList === null
|
||||
? ''
|
||||
: this.offerList.map((offer) =>
|
||||
raw(this.generateOfferHtml(offer))
|
||||
)}
|
||||
</ul>`.toString();
|
||||
this.setupDomListeners();
|
||||
}
|
||||
|
||||
protected generateOfferHtml(offer: IOfferPreview): string {
|
||||
return html`<li
|
||||
class="our-coffee-api-offer-list-offer"
|
||||
data-offer-id="${attrValue(offer.offerId)}"
|
||||
>
|
||||
${offer.imageUrl !== undefined
|
||||
? `<img src="${attrValue(offer.imageUrl)}">`
|
||||
: ''}
|
||||
<h3>${offer.title}</h3>
|
||||
<p>${offer.subtitle}</p>
|
||||
<p>${offer.bottomLine}</p>
|
||||
<aside>${offer.price.formattedValue}</aside>
|
||||
</li>`.toString();
|
||||
}
|
||||
|
||||
protected onClickListener = (e: MouseEvent) => {
|
||||
let target = e.target;
|
||||
while (target) {
|
||||
const offerId = (<HTMLElement>target).dataset?.offerId;
|
||||
if (offerId !== undefined) {
|
||||
this.onOfferClick(offerId);
|
||||
break;
|
||||
}
|
||||
target = (<HTMLElement>target).parentNode;
|
||||
}
|
||||
};
|
||||
|
||||
protected onOfferClick(offerId: string) {
|
||||
this.selectOffer(offerId);
|
||||
}
|
||||
|
||||
protected setupListeners() {
|
||||
this.listenerDisposers.push(
|
||||
this.context.events.on(
|
||||
'offerPreviewListChange',
|
||||
this.onOfferListChange
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected setupDomListeners() {
|
||||
this.container.addEventListener('click', this.onClickListener, false);
|
||||
}
|
||||
|
||||
protected teardownListeners() {
|
||||
for (const disposer of this.listenerDisposers) {
|
||||
disposer.off();
|
||||
}
|
||||
this.listenerDisposers = [];
|
||||
}
|
||||
|
||||
protected teardownDomListeners() {
|
||||
this.container.removeEventListener(
|
||||
'click',
|
||||
this.onClickListener,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,188 @@
|
||||
import {
|
||||
IButton,
|
||||
IButtonEvents,
|
||||
IButtonOptions,
|
||||
IButtonPressEvent
|
||||
} from './interfaces/IButton';
|
||||
import {
|
||||
IOfferPanelComponent,
|
||||
IOfferPanelComponentEvents,
|
||||
IOfferPanelComponentOptions
|
||||
} from './interfaces/IOfferPanelComponent';
|
||||
import {
|
||||
IOfferFullView,
|
||||
ISearchBoxComposer
|
||||
} from './interfaces/ISearchBoxComposer';
|
||||
import { IDisposer, IEventEmitter } from './interfaces/common';
|
||||
import { EventEmitter } from './util/EventEmitter';
|
||||
import { $, attrValue, html } from './util/html';
|
||||
|
||||
export class OfferPanelComponent implements IOfferPanelComponent {
|
||||
public events: IEventEmitter<IOfferPanelComponentEvents> =
|
||||
new EventEmitter();
|
||||
|
||||
protected buttons: Array<{
|
||||
button: IButton;
|
||||
listenerDisposer: IDisposer;
|
||||
}>;
|
||||
protected listenerDisposers: IDisposer[] = [];
|
||||
|
||||
constructor(
|
||||
protected readonly context: ISearchBoxComposer,
|
||||
protected readonly container: HTMLElement,
|
||||
protected currentOffer: IOfferFullView | null = null,
|
||||
protected readonly options: IOfferPanelComponentOptions,
|
||||
protected readonly buttonBuilders: ButtonBuilder[] = [
|
||||
OfferPanelComponent.buildCreateOrderButton,
|
||||
OfferPanelComponent.buildCloseButton
|
||||
]
|
||||
) {
|
||||
this.render();
|
||||
this.setupButtons();
|
||||
this.listenerDisposers.push(
|
||||
this.context.events.on('offerFullViewToggle', ({ offer }) => {
|
||||
this.currentOffer = offer;
|
||||
this.render();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public static buildCreateOrderButton: ButtonBuilder = (
|
||||
container,
|
||||
options
|
||||
) =>
|
||||
new CreateOrderButton(container, {
|
||||
iconUrl: options.createOrderButtonUrl,
|
||||
text: options.createOrderButtonText
|
||||
});
|
||||
|
||||
public static buildCloseButton: ButtonBuilder = (container, options) =>
|
||||
new CloseButton(container, {
|
||||
iconUrl: options.closeButtonUrl,
|
||||
text: options.closeButtonText
|
||||
});
|
||||
|
||||
public destroy() {
|
||||
this.currentOffer = null;
|
||||
for (const disposer of this.listenerDisposers) {
|
||||
disposer.off();
|
||||
}
|
||||
this.destroyButtons();
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
|
||||
protected render() {
|
||||
this.destroyButtons();
|
||||
this.container.innerHTML =
|
||||
this.currentOffer === null
|
||||
? ''
|
||||
: html`<h1>${this.currentOffer.title}</h1>
|
||||
${this.currentOffer.description.map(
|
||||
(description) => html`<p>${description}</p>`
|
||||
)}
|
||||
<ul
|
||||
class="out-coffee-sdk-offer-panel-buttons"
|
||||
></ul>`.toString();
|
||||
this.setupButtons();
|
||||
}
|
||||
|
||||
protected setupButtons() {
|
||||
const buttonsContainer = $(
|
||||
this.container,
|
||||
'.out-coffee-sdk-offer-panel-buttons'
|
||||
);
|
||||
for (const buttonBuilder of this.buttonBuilders) {
|
||||
const buttonContainer = document.createElement('li');
|
||||
buttonsContainer.appendChild(buttonContainer);
|
||||
const button = buttonBuilder(buttonContainer, this.options);
|
||||
const listenerDisposer = button.events.on(
|
||||
'press',
|
||||
this.onButtonPress
|
||||
);
|
||||
this.buttons.push({ button, listenerDisposer });
|
||||
}
|
||||
}
|
||||
|
||||
protected destroyButtons() {
|
||||
for (const { button, listenerDisposer } of this.buttons) {
|
||||
listenerDisposer.off();
|
||||
button.destroy();
|
||||
}
|
||||
this.buttons = [];
|
||||
$(this.container, '.out-coffee-sdk-offer-panel-buttons').innerHTML = '';
|
||||
}
|
||||
|
||||
protected onButtonPress = ({ target: { action } }: IButtonPressEvent) => {
|
||||
if (this.currentOffer !== null) {
|
||||
this.events.emit('action', {
|
||||
action,
|
||||
offerId: this.currentOffer.offerId
|
||||
});
|
||||
} else {
|
||||
// TBD
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type ButtonBuilder = (
|
||||
container: HTMLElement,
|
||||
options: IOfferPanelComponentOptions
|
||||
) => IButton;
|
||||
|
||||
export class OfferPanelButton implements IButton {
|
||||
public events: IEventEmitter<IButtonEvents> = new EventEmitter();
|
||||
|
||||
protected onClickListener = () => {
|
||||
this.events.emit('press', { target: this });
|
||||
};
|
||||
|
||||
protected button: HTMLButtonElement;
|
||||
|
||||
constructor(
|
||||
public readonly action: string,
|
||||
protected readonly container: HTMLElement,
|
||||
protected readonly options: IButtonOptions
|
||||
) {
|
||||
this.container.innerHTML = html`<button
|
||||
class="our-coffee-api-sdk-offer-panel-button"
|
||||
>
|
||||
${this.options.iconUrl
|
||||
? html`<img src="${attrValue(this.options.iconUrl)}" />`
|
||||
: ''}
|
||||
<span>${this.options.text}</span>
|
||||
</button>`.toString();
|
||||
this.button = $<HTMLButtonElement>(
|
||||
this.container,
|
||||
'.our-coffee-api-sdk-offer-panel-button'
|
||||
);
|
||||
|
||||
this.button.addEventListener('click', this.onClickListener, false);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.button.removeEventListener('click', this.onClickListener, false);
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
export class CreateOrderButton extends OfferPanelButton {
|
||||
constructor(container: HTMLElement, options: Partial<IButtonOptions>) {
|
||||
super('createOrder', container, {
|
||||
text: 'Place an Order',
|
||||
...options
|
||||
});
|
||||
this.button.classList.add(
|
||||
'our-coffee-sdk-offer-panel-create-order-button'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class CloseButton extends OfferPanelButton {
|
||||
constructor(container: HTMLElement, options: Partial<IButtonOptions>) {
|
||||
super('close', container, {
|
||||
text: 'Not Now',
|
||||
...options
|
||||
});
|
||||
this.button.classList.add('our-coffee-sdk-offer-panel-close-button');
|
||||
}
|
||||
}
|
133
docs/examples/01. Decomposing UI Components/src/SearchBox.ts
Normal file
133
docs/examples/01. Decomposing UI Components/src/SearchBox.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { SearchBoxComposer } from './SearchBoxComposer';
|
||||
|
||||
import { $, html } from './util/html';
|
||||
import { ICoffeeApi, INewOrder, ISearchResult } from './interfaces/ICoffeeApi';
|
||||
import {
|
||||
ISearchBox,
|
||||
ISearchBoxEvents,
|
||||
ISearchBoxOptions
|
||||
} from './interfaces/ISearchBox';
|
||||
import { ISearchBoxComposer } from './interfaces/ISearchBoxComposer';
|
||||
import { IDisposer, IEventEmitter } from './interfaces/common';
|
||||
import { EventEmitter } from './util/EventEmitter';
|
||||
|
||||
export class SearchBox implements ISearchBox {
|
||||
public readonly events: IEventEmitter<ISearchBoxEvents> =
|
||||
new EventEmitter();
|
||||
|
||||
protected readonly options: SearchBoxOptions;
|
||||
protected readonly composer: ISearchBoxComposer;
|
||||
protected offerList: ISearchResult[] | null = null;
|
||||
protected currentRequest: Promise<ISearchResult[]> | null = null;
|
||||
protected searchButton: HTMLButtonElement;
|
||||
protected listenerDisposers: IDisposer[] = [];
|
||||
|
||||
public constructor(
|
||||
protected readonly parentNode: HTMLElement,
|
||||
protected readonly coffeeApi: ICoffeeApi,
|
||||
options: ISearchBoxOptions
|
||||
) {
|
||||
this.options = {
|
||||
...SearchBox.DEFAULT_OPTIONS,
|
||||
...options
|
||||
};
|
||||
this.render();
|
||||
this.composer = this.buildComposer();
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
public getOptions() {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
public getContainer() {
|
||||
return this.parentNode;
|
||||
}
|
||||
|
||||
public search(query: string): void {
|
||||
const request = (this.currentRequest = this.coffeeApi.search(query));
|
||||
this.setOfferList(null);
|
||||
request.then((result: ISearchResult[]) => {
|
||||
if (request === this.currentRequest) {
|
||||
this.setOfferList(result);
|
||||
this.currentRequest = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getOfferList() {
|
||||
return this.offerList;
|
||||
}
|
||||
|
||||
public createOrder(parameters: { offerId: string }): Promise<INewOrder> {
|
||||
return this.coffeeApi.createOrder(parameters);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.composer.destroy();
|
||||
this.currentRequest = this.offerList = null;
|
||||
}
|
||||
|
||||
public buildComposer() {
|
||||
return new SearchBoxComposer(this, this.parentNode, this.options);
|
||||
}
|
||||
|
||||
public static DEFAULT_OPTIONS: SearchBoxOptions = {
|
||||
searchButtonText: 'Search'
|
||||
};
|
||||
|
||||
protected render() {
|
||||
this.parentNode.innerHTML = html`<div class="our-coffee-sdk-search-box">
|
||||
<div class="our-coffee-sdk-search-box-head">
|
||||
<input type="text" class="our-coffee-sdk-search-box-input" />
|
||||
<button class="our-coffee-sdk-search-box-search-button">
|
||||
${this.options.searchButtonText}
|
||||
</button>
|
||||
</div>
|
||||
</div>`.toString();
|
||||
this.searchButton = $(
|
||||
this.parentNode,
|
||||
'.our-coffee-sdk-search-box-search-button'
|
||||
);
|
||||
}
|
||||
|
||||
protected onSearchButtonClickListener = () => {
|
||||
this.search(this.searchButton.value);
|
||||
};
|
||||
protected setupListeners() {
|
||||
this.searchButton.addEventListener(
|
||||
'click',
|
||||
this.onSearchButtonClickListener,
|
||||
false
|
||||
);
|
||||
this.listenerDisposers.push(
|
||||
this.composer.events.on('createOrder', ({ offer: offerId }) =>
|
||||
this.createOrder(offerId)
|
||||
)
|
||||
);
|
||||
}
|
||||
protected teardownListeners() {
|
||||
for (const disposer of this.listenerDisposers) {
|
||||
disposer.off();
|
||||
}
|
||||
this.listenerDisposers = [];
|
||||
this.searchButton.removeEventListener(
|
||||
'click',
|
||||
this.onSearchButtonClickListener,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
protected setOfferList(offerList: ISearchResult[] | null) {
|
||||
this.offerList = offerList;
|
||||
this.events.emit('offerListChange', { offerList });
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchBoxOptions extends ISearchBoxOptions {
|
||||
searchButtonText: string;
|
||||
}
|
||||
|
||||
export type SearchBoxComposerBuilder = (
|
||||
context: SearchBox
|
||||
) => ISearchBoxComposer;
|
@ -0,0 +1,211 @@
|
||||
import { ISearchResult } from './interfaces/ICoffeeApi';
|
||||
import {
|
||||
ISearchBox,
|
||||
ISearchBoxOfferListChangeEvent,
|
||||
ISearchBoxOptions
|
||||
} from './interfaces/ISearchBox';
|
||||
import {
|
||||
IOfferFullView,
|
||||
IOfferPreview,
|
||||
ISearchBoxComposer,
|
||||
ISearchBoxComposerEvents
|
||||
} from './interfaces/ISearchBoxComposer';
|
||||
import {
|
||||
IOfferListComponent,
|
||||
IOfferListComponentOptions,
|
||||
IOfferSelectedEvent
|
||||
} from './interfaces/IOfferListComponent';
|
||||
import {
|
||||
IOfferPanelActionEvent,
|
||||
IOfferPanelComponent,
|
||||
IOfferPanelComponentOptions
|
||||
} from './interfaces/IOfferPanelComponent';
|
||||
import { IDisposer, IEventEmitter } from './interfaces/common';
|
||||
|
||||
import { OfferListComponent } from './OfferListComponent';
|
||||
import { OfferPanelComponent } from './OfferPanelComponent';
|
||||
import { EventEmitter } from './util/EventEmitter';
|
||||
|
||||
export class SearchBoxComposer implements ISearchBoxComposer {
|
||||
public events: IEventEmitter<ISearchBoxComposerEvents> =
|
||||
new EventEmitter<ISearchBoxComposerEvents>();
|
||||
|
||||
protected offerListContainer: HTMLElement | null = null;
|
||||
protected offerListComponent: IOfferListComponent | null = null;
|
||||
protected offerPanelContainer: HTMLElement | null = null;
|
||||
protected offerPanelComponent: IOfferPanelComponent | null = null;
|
||||
protected offerList: ISearchResult[] | null;
|
||||
protected currentOffer: ISearchResult | null;
|
||||
|
||||
protected listenerDisposers: IDisposer[];
|
||||
|
||||
constructor(
|
||||
protected readonly context: ISearchBox,
|
||||
protected readonly container: HTMLElement,
|
||||
protected readonly contextOptions: ISearchBoxOptions
|
||||
) {
|
||||
this.offerListContainer = document.createElement('div');
|
||||
container.appendChild(this.offerListContainer);
|
||||
this.offerListComponent = this.buildOfferListComponent();
|
||||
this.offerPanelContainer = document.createElement('div');
|
||||
container.appendChild(this.offerPanelContainer);
|
||||
this.offerPanelComponent = this.buildOfferPanelComponent();
|
||||
|
||||
this.listenerDisposers = [
|
||||
context.events.on('offerListChange', this.onContextOfferListChange),
|
||||
this.offerListComponent.events.on(
|
||||
'offerSelect',
|
||||
this.onOfferListOfferSelect
|
||||
),
|
||||
this.offerPanelComponent.events.on(
|
||||
'action',
|
||||
this.onOfferPanelAction
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
for (const disposer of this.listenerDisposers) {
|
||||
disposer.off();
|
||||
}
|
||||
this.listenerDisposers = [];
|
||||
|
||||
this.offerListComponent.destroy();
|
||||
this.offerPanelComponent.destroy();
|
||||
|
||||
this.offerListContainer.parentNode.removeChild(this.offerListContainer);
|
||||
this.offerPanelContainer.parentNode.removeChild(
|
||||
this.offerPanelContainer
|
||||
);
|
||||
}
|
||||
|
||||
protected onContextOfferListChange = ({
|
||||
offerList
|
||||
}: ISearchBoxOfferListChangeEvent) => {
|
||||
if (this.currentOffer !== null) {
|
||||
this.currentOffer = null;
|
||||
this.events.emit('offerFullViewToggle', { offer: null });
|
||||
}
|
||||
|
||||
this.offerList = offerList;
|
||||
this.events.emit('offerPreviewListChange', {
|
||||
offerList: this.buildOfferPreviews()
|
||||
});
|
||||
};
|
||||
|
||||
protected onOfferPanelAction({ action, offerId }: IOfferPanelActionEvent) {
|
||||
switch (action) {
|
||||
case 'createOrder':
|
||||
const offer = this.findOfferById(offerId);
|
||||
// Offer may be missing if `OfferPanelComponent`
|
||||
// renders offers asynchronously
|
||||
if (offer !== null) {
|
||||
this.events.emit('createOrder', { offer });
|
||||
}
|
||||
break;
|
||||
case 'close':
|
||||
if (this.currentOffer !== null) {
|
||||
this.currentOffer = null;
|
||||
this.events.emit('offerFullViewToggle', { offer: null });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected onOfferListOfferSelect({ offerId }: IOfferSelectedEvent) {
|
||||
const offer = this.findOfferById(offerId);
|
||||
// Offer may be missing for a variety of reasons,
|
||||
// most notably of `OfferListComponent` renders
|
||||
// offers asynchronously
|
||||
if (offer !== null) {
|
||||
this.currentOffer = offer;
|
||||
this.events.emit('offerFullViewToggle', {
|
||||
offer: this.generateCurrentOfferFullView()
|
||||
});
|
||||
} else {
|
||||
// TDB
|
||||
}
|
||||
}
|
||||
|
||||
private findOfferById(offerIdToFind: string): ISearchResult | null {
|
||||
// Theoretically, we could have built a `Map`
|
||||
// for quickly searching offers by their id
|
||||
// Practically, as all responses in an API must be
|
||||
// paginated, it makes little sense to optimize this.
|
||||
return (
|
||||
this.offerList?.find(({ offerId }) => offerId === offerIdToFind) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
protected buildOfferListComponent() {
|
||||
return new OfferListComponent(
|
||||
this,
|
||||
this.offerListContainer,
|
||||
this.buildOfferPreviews(),
|
||||
this.generateOfferListComponentOptions()
|
||||
);
|
||||
}
|
||||
|
||||
protected buildOfferPreviews(): IOfferPreview[] | null {
|
||||
return this.offerList === null
|
||||
? null
|
||||
: this.offerList.map((offer) => ({
|
||||
offerId: offer.offerId,
|
||||
title: offer.place.title,
|
||||
subtitle: offer.recipe.title,
|
||||
price: offer.price,
|
||||
bottomLine: this.generateOfferBottomLine(offer)
|
||||
}));
|
||||
}
|
||||
|
||||
protected generateOfferListComponentOptions(): IOfferListComponentOptions {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected buildOfferPanelComponent() {
|
||||
return new OfferPanelComponent(
|
||||
this,
|
||||
this.offerPanelContainer,
|
||||
this.generateCurrentOfferFullView(),
|
||||
this.buildOfferPanelComponentOptions()
|
||||
);
|
||||
}
|
||||
|
||||
protected generateCurrentOfferFullView(): IOfferFullView | null {
|
||||
const offer = this.currentOffer;
|
||||
return offer === null
|
||||
? null
|
||||
: {
|
||||
offerId: offer.offerId,
|
||||
title: offer.place.title,
|
||||
description: [
|
||||
offer.recipe.mediumDescription,
|
||||
this.generateOfferBottomLine(offer)
|
||||
],
|
||||
price: offer.price
|
||||
};
|
||||
}
|
||||
|
||||
protected buildOfferPanelComponentOptions(): IOfferPanelComponentOptions {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected generateOfferBottomLine(offer: ISearchResult): string {
|
||||
return `${offer.place.walkTime.formattedValue} · ${offer.place.walkingDistance.formattedValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
export type OfferListComponentBuilder = (
|
||||
context: ISearchBoxComposer,
|
||||
container: HTMLElement,
|
||||
offerList: IOfferPreview[] | null,
|
||||
options: IOfferListComponentOptions
|
||||
) => IOfferListComponent;
|
||||
|
||||
export type OfferPanelComponentBuilder = (
|
||||
context: ISearchBoxComposer,
|
||||
container: HTMLElement,
|
||||
offer: IOfferFullView | null,
|
||||
options: IOfferPanelComponentOptions
|
||||
) => IOfferPanelComponent;
|
@ -0,0 +1,20 @@
|
||||
import { IEventEmitter } from './common';
|
||||
|
||||
export interface IButton {
|
||||
action: string;
|
||||
events: IEventEmitter<IButtonEvents>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export interface IButtonOptions {
|
||||
iconUrl?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface IButtonEvents {
|
||||
press: IButtonPressEvent;
|
||||
}
|
||||
|
||||
export interface IButtonPressEvent {
|
||||
target: IButton;
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import {
|
||||
IFormattedDistance,
|
||||
IFormattedDuration,
|
||||
IFormattedPrice
|
||||
} from './common';
|
||||
|
||||
export interface ICoffeeApi {
|
||||
search: (query: string) => Promise<ISearchResult[]>;
|
||||
createOrder: (parameters: { offerId: string }) => Promise<INewOrder>;
|
||||
}
|
||||
|
||||
export interface ISearchResult {
|
||||
offerId: string;
|
||||
recipe: {
|
||||
title: string;
|
||||
shortDescription: string;
|
||||
mediumDescription: string;
|
||||
};
|
||||
place: {
|
||||
title: string;
|
||||
walkingDistance: IFormattedDistance;
|
||||
walkTime: IFormattedDuration;
|
||||
};
|
||||
price: IFormattedPrice;
|
||||
}
|
||||
|
||||
export interface INewOrder {
|
||||
orderId: string;
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import { IEventEmitter } from './common';
|
||||
|
||||
export interface IOfferListComponent {
|
||||
events: IEventEmitter<IOfferListEvents>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export interface IOfferListComponentOptions {}
|
||||
|
||||
export interface IOfferListEvents {
|
||||
offerSelect: IOfferSelectedEvent;
|
||||
}
|
||||
|
||||
export interface IOfferSelectedEvent {
|
||||
offerId: string;
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
import { IEventEmitter } from './common';
|
||||
|
||||
export interface IOfferPanelComponent {
|
||||
events: IEventEmitter<IOfferPanelComponentEvents>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export interface IOfferPanelComponentOptions {
|
||||
createOrderButtonUrl?: string;
|
||||
createOrderButtonText?: string;
|
||||
closeButtonUrl?: string;
|
||||
closeButtonText?: string;
|
||||
}
|
||||
|
||||
export interface IOfferPanelComponentEvents {
|
||||
action: IOfferPanelActionEvent;
|
||||
}
|
||||
|
||||
export interface IOfferPanelActionEvent {
|
||||
action: string;
|
||||
offerId: string;
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
import { INewOrder, ISearchResult } from './ICoffeeApi';
|
||||
import { IEventEmitter } from './common';
|
||||
|
||||
export interface ISearchBox {
|
||||
events: IEventEmitter<ISearchBoxEvents>;
|
||||
search: (query: string) => void;
|
||||
getOfferList: () => ISearchResult[] | null;
|
||||
createOrder: (parameters: { offerId: string }) => Promise<INewOrder>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export interface ISearchBoxOptions {}
|
||||
|
||||
export interface ISearchBoxEvents {
|
||||
offerListChange: ISearchBoxOfferListChangeEvent;
|
||||
}
|
||||
|
||||
export interface ISearchBoxOfferListChangeEvent {
|
||||
offerList: ISearchResult[] | null;
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
import { ISearchResult } from './ICoffeeApi';
|
||||
import { IEventEmitter, IFormattedPrice } from './common';
|
||||
|
||||
export interface ISearchBoxComposer {
|
||||
events: IEventEmitter<ISearchBoxComposerEvents>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export interface ISearchBoxComposerEvents {
|
||||
offerPreviewListChange: IOfferPreviewListChangeEvent;
|
||||
offerFullViewToggle: IOfferFullViewToggleEvent;
|
||||
createOrder: ICreateOrderEvent;
|
||||
}
|
||||
|
||||
export interface IOfferPreviewListChangeEvent {
|
||||
offerList: IOfferPreview[] | null;
|
||||
}
|
||||
|
||||
export interface IOfferFullViewToggleEvent {
|
||||
offer: IOfferFullView | null;
|
||||
}
|
||||
|
||||
export interface ICreateOrderEvent {
|
||||
offer: ISearchResult;
|
||||
}
|
||||
|
||||
export interface IOfferPreview {
|
||||
offerId: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
bottomLine: string;
|
||||
imageUrl?: string;
|
||||
price: IFormattedPrice;
|
||||
}
|
||||
|
||||
export interface IOfferFullView {
|
||||
offerId: string;
|
||||
title: string;
|
||||
description: string[];
|
||||
price: IFormattedPrice;
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
export interface IFormattedPrice {
|
||||
decimalValue: string;
|
||||
formattedValue: string;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
export interface IFormattedDistance {
|
||||
numericValue: number;
|
||||
formattedValue: string;
|
||||
}
|
||||
|
||||
export interface IFormattedDuration {
|
||||
intervalValueSeconds: number;
|
||||
formattedValue: string;
|
||||
}
|
||||
|
||||
export interface IEventEmitter<EventList extends Record<string, any>> {
|
||||
on: <Type extends Extract<keyof EventList, string>>(
|
||||
type: Type,
|
||||
callback: (event: EventList[Type]) => void
|
||||
) => IDisposer;
|
||||
|
||||
emit: <Type extends Extract<keyof EventList, string>>(
|
||||
type: Type,
|
||||
event: EventList[Type]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface IDisposer {
|
||||
off: () => void;
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
import { IDisposer, IEventEmitter } from '../interfaces/common';
|
||||
|
||||
export class EventEmitter<EventList extends Record<string, any>>
|
||||
implements IEventEmitter<EventList>
|
||||
{
|
||||
constructor() {}
|
||||
|
||||
protected readonly listenersByType = new Map<
|
||||
string,
|
||||
Map<Disposer, Function>
|
||||
>();
|
||||
|
||||
protected disposeCallback = (type: string, disposer: Disposer) => {
|
||||
const listeners = this.listenersByType.get(type);
|
||||
if (listeners !== undefined) {
|
||||
const callback = listeners.get(disposer);
|
||||
if (callback !== undefined) {
|
||||
listeners.delete(disposer);
|
||||
if (listeners.size === 0) {
|
||||
this.listenersByType.delete(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public on<Type extends Extract<keyof EventList, string>>(
|
||||
type: Type,
|
||||
callback: (event: EventList[Type]) => void
|
||||
): Disposer {
|
||||
const disposer = new Disposer(type, this.disposeCallback);
|
||||
const listeners = this.listenersByType.get(type);
|
||||
if (listeners === undefined) {
|
||||
this.listenersByType.set(
|
||||
type,
|
||||
new Map<Disposer, Function>([[disposer, callback]])
|
||||
);
|
||||
} else {
|
||||
listeners.set(disposer, callback);
|
||||
}
|
||||
return disposer;
|
||||
}
|
||||
|
||||
public emit<Type extends Extract<keyof EventList, string>>(
|
||||
type: Type,
|
||||
event: EventList[Type]
|
||||
) {
|
||||
const listeners = this.listenersByType.get(type);
|
||||
if (listeners !== undefined) {
|
||||
for (const callback of listeners.values()) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Disposer implements IDisposer {
|
||||
protected active = true;
|
||||
|
||||
constructor(
|
||||
protected readonly type: string,
|
||||
protected readonly callback: (type: string, disposer: Disposer) => void
|
||||
) {}
|
||||
|
||||
public off() {
|
||||
if (this.active) {
|
||||
this.callback(this.type, this);
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
}
|
95
docs/examples/01. Decomposing UI Components/src/util/html.ts
Normal file
95
docs/examples/01. Decomposing UI Components/src/util/html.ts
Normal file
@ -0,0 +1,95 @@
|
||||
export function $<T extends Element>(
|
||||
...args: [HTMLElement, string] | [string]
|
||||
): T {
|
||||
const [node, selector] = args.length === 2 ? args : [document, args[0]];
|
||||
const result = node.querySelector(selector);
|
||||
if (!result) {
|
||||
throw new Error(`Cannot select a node with "${selector}" selector`);
|
||||
}
|
||||
return <T>result;
|
||||
}
|
||||
|
||||
export function makeTemplate(e: (str: string) => string) {
|
||||
return (
|
||||
texts: TemplateStringsArray,
|
||||
...substitutes: Array<
|
||||
string | HtmlSerializable | Array<string | HtmlSerializable>
|
||||
>
|
||||
): HtmlSerializable => {
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
parts.push(texts[i]);
|
||||
if (substitutes[i]) {
|
||||
parts.push(getSubstituteValue(substitutes[i], e));
|
||||
}
|
||||
}
|
||||
return new HtmlSerializable(parts.join(''));
|
||||
};
|
||||
}
|
||||
|
||||
export class HtmlSerializable {
|
||||
public __isHtmlSerializable: boolean;
|
||||
constructor(private readonly __html: string) {
|
||||
this.__isHtmlSerializable = true;
|
||||
}
|
||||
toString(): string {
|
||||
return this.__html;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSubstituteValue(
|
||||
value: string | HtmlSerializable | Array<string | HtmlSerializable>,
|
||||
escapeFunction: (str: string) => string
|
||||
): string {
|
||||
if (typeof value == 'string') {
|
||||
return escapeFunction(value);
|
||||
} else if ((<HtmlSerializable>value).__isHtmlSerializable) {
|
||||
return value.toString();
|
||||
} else {
|
||||
return (<Array<string | HtmlSerializable>>value)
|
||||
.map((v) => getSubstituteValue(v, escapeFunction))
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
export function htmlEscape(str: string): string {
|
||||
return str
|
||||
.replace(/\&/g, '&')
|
||||
.replace(/\</g, '<')
|
||||
.replace(/\>/g, '>');
|
||||
}
|
||||
|
||||
export function attrEscape(str: string): string {
|
||||
return htmlEscape(str).replace(/\'/g, ''').replace(/\"/g, '"');
|
||||
}
|
||||
|
||||
export function hrefEscapeBuilder(
|
||||
allowedProtocols = ['http:', 'https:', null]
|
||||
) {
|
||||
return (raw: string) => {
|
||||
const str = raw.trim();
|
||||
const protocol = str.match(/$[a-z0-9\+\-\.]+\:/i)[0] ?? null;
|
||||
if (allowedProtocols.includes(protocol)) {
|
||||
return attrEscape(str);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const httpHrefEscape = hrefEscapeBuilder();
|
||||
|
||||
export const html = makeTemplate(htmlEscape);
|
||||
export const raw = (str: string) => new HtmlSerializable(str);
|
||||
export const attr = makeTemplate(attrEscape);
|
||||
export const attrValue = (str: string) => new HtmlSerializable(attrEscape(str));
|
||||
export const href = makeTemplate(httpHrefEscape);
|
||||
export const hrefValue = (
|
||||
str: string,
|
||||
allowedProtocols?: Array<string | null>
|
||||
) =>
|
||||
new HtmlSerializable(
|
||||
allowedProtocols === undefined
|
||||
? httpHrefEscape(str)
|
||||
: hrefEscapeBuilder(allowedProtocols)(str)
|
||||
);
|
@ -0,0 +1,3 @@
|
||||
{
|
||||
"compilerOptions": {"target": "ES2015"}
|
||||
}
|
26
docs/examples/fonts.css
Normal file
26
docs/examples/fonts.css
Normal file
@ -0,0 +1,26 @@
|
||||
@font-face {
|
||||
font-family: local-serif;
|
||||
src: url(../docs/assets/PTSerif-Regular.ttf);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-serif;
|
||||
src: url(../docs/assets/PTSerif-Bold.ttf);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-sans;
|
||||
src: url(../docs/assets/PTSans-Regular.ttf);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-sans;
|
||||
src: url(../docs/assets/PTSans-Bold.ttf);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-monospace;
|
||||
src: url(../docs/assets/RobotoMono-Regular.ttf);
|
||||
}
|
@ -9,7 +9,8 @@
|
||||
"@twirl/book-builder": "0.0.23",
|
||||
"html-docx-js": "^0.3.1",
|
||||
"nodemon": "^2.0.19",
|
||||
"puppeteer": "^13.1.2"
|
||||
"puppeteer": "^13.1.2",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
|
BIN
src/fonts/PTSans-Bold copy.ttf
Normal file
BIN
src/fonts/PTSans-Bold copy.ttf
Normal file
Binary file not shown.
BIN
src/fonts/PTSans-Regular copy.ttf
Normal file
BIN
src/fonts/PTSans-Regular copy.ttf
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user