mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-06-12 22:17:33 +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",
|
"@twirl/book-builder": "0.0.23",
|
||||||
"html-docx-js": "^0.3.1",
|
"html-docx-js": "^0.3.1",
|
||||||
"nodemon": "^2.0.19",
|
"nodemon": "^2.0.19",
|
||||||
"puppeteer": "^13.1.2"
|
"puppeteer": "^13.1.2",
|
||||||
|
"typescript": "^5.1.6"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.mjs",
|
"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…
x
Reference in New Issue
Block a user