1
0
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:
Sergey Konstantinov 2023-07-23 13:48:09 +03:00
parent c3dba5c1bc
commit 15ca4b3417
21 changed files with 1163 additions and 1 deletions

Binary file not shown.

View 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.

View 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>

View File

@ -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
);
}
}

View File

@ -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');
}
}

View 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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}
}

View 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, '&amp;')
.replace(/\</g, '&lt;')
.replace(/\>/g, '&gt;');
}
export function attrEscape(str: string): string {
return htmlEscape(str).replace(/\'/g, '&#39;').replace(/\"/g, '&quot;');
}
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)
);

View File

@ -0,0 +1,3 @@
{
"compilerOptions": {"target": "ES2015"}
}

26
docs/examples/fonts.css Normal file
View 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);
}

View File

@ -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",

Binary file not shown.

Binary file not shown.