1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-05-25 22:08:06 +02:00

examples refactoring

This commit is contained in:
Sergey Konstantinov 2023-08-01 01:25:30 +03:00
parent c35d5ae61d
commit 31e9a55e6c
19 changed files with 711 additions and 384 deletions

File diff suppressed because one or more lines are too long

View File

@ -88,7 +88,8 @@
>
</p>
<p>
<a href="https://twirl.github.io/The-API-Book/#sdk-decomposing"
<a
href="https://twirl.github.io/The-API-Book/API.en.html#chapter-44"
>Chapter 43: Problems of Introducing UI Components</a
>. Find the source code of the components and additional tasks
for self-study at the
@ -112,25 +113,19 @@
</li>
<li id="example-01">
<a href="javascript:showExample('01')"
>Example #1: A <code>SearchBox</code> with a custom
icon</a
>Example #1: A <code>SearchBox</code> with a map</a
>
</li>
<li id="example-02">
<a href="javascript:showExample('02')"
>Example #2: A <code>SearchBox</code> with a map</a
>Example #2: A <code>SearchBox</code> with in-place
actions</a
>
</li>
<li id="example-03">
<a href="javascript:showExample('03')"
>Example #3: A <code>SearchBox</code> with in-place
actions</a
>
</li>
<li id="example-04">
<a href="javascript:showExample('04')"
>Example #4: A <code>SearchBox</code> with a dynamic
set of buttons</a
>Example #3: A <code>SearchBox</code> with custom
buttons</a
>
</li>
</ul>

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{
"printWidth": 50,
"printWidth": 60,
"tabWidth": 2,
"trailingComma": "none"
}

View File

@ -1,54 +1,101 @@
const buildCustomOrderButton = function (
offer,
container
) {
return ourCoffeeSdk.OfferPanelComponent.buildCreateOrderButton(
offer,
container,
{
createOrderButtonUrl:
offer && offer.createOrderButtonIcon,
createOrderButtonText:
(offer &&
`Buy now for just ${offer.price.formattedValue}`) ||
"Place an Order"
const {
SearchBox,
SearchBoxComposer,
DummyMapApi,
dummyCoffeeApi,
util
} = ourCoffeeSdk;
class CustomOfferList {
constructor(context, container, offerList) {
this.context = context;
this.container = container;
this.events = new util.EventEmitter();
this.offerList = null;
this.map = null;
this.onMarkerClick = (markerId) => {
this.events.emit("offerSelect", {
offerId: markerId
});
};
this.setOfferList = ({ offerList: newOfferList }) => {
if (this.map) {
this.map.destroy();
this.map = null;
}
this.offerList = newOfferList;
if (newOfferList) {
this.map = new DummyMapApi(this.container, [
[16.355, 48.2],
[16.375, 48.214]
]);
for (const offer of newOfferList) {
this.map.addMarker(
offer.offerId,
offer.location,
this.onMarkerClick
);
}
}
};
class CustomComposer extends ourCoffeeSdk.SearchBoxComposer {
generateOfferPreviews(offerList) {
const result = super.generateOfferPreviews(
offerList
this.setOfferList({ offerList });
this.contextListeners = [
context.events.on(
"offerPreviewListChange",
this.setOfferList
),
context.events.on(
"offerFullViewToggle",
({ offer }) => {
this.map.selectSingleMarker(
offer && offer.offerId
);
}
)
];
}
destroy() {
if (this.map) {
this.map.destroy();
}
for (const listener of this.contextListeners) {
listener.off();
}
}
}
class CustomComposer extends SearchBoxComposer {
buildOfferListComponent(
context,
container,
offerList,
contextOptions
) {
return new CustomOfferList(
context,
container,
this.generateOfferPreviews(offerList, contextOptions),
this.generateOfferListComponentOptions(contextOptions)
);
}
generateOfferPreviews(offerList) {
const result = super.generateOfferPreviews(offerList);
return result === null
? result
: result.map((preview, index) => ({
...preview,
imageUrl: offerList[index].place.icon
location: offerList[index].place.location
}));
}
generateCurrentOfferFullView(offer, options) {
return offer === null
? offer
: {
...super.generateCurrentOfferFullView(
offer,
options
),
createOrderButtonIcon: offer.place.icon
};
}
}
class CustomSearchBox extends ourCoffeeSdk.SearchBox {
class CustomSearchBox extends SearchBox {
buildComposer(context, container, options) {
return new CustomComposer(
context,
container,
options
);
return new CustomComposer(context, container, options);
}
createOrder(offer) {
@ -59,15 +106,10 @@ class CustomSearchBox extends ourCoffeeSdk.SearchBox {
const searchBox = new CustomSearchBox(
document.getElementById("search-box"),
ourCoffeeSdk.dummyCoffeeApi,
dummyCoffeeApi,
{
offerPanel: {
buttonBuilders: [
buildCustomOrderButton,
ourCoffeeSdk.OfferPanelComponent
.buildCloseButton
],
closeButtonText: "❌Not Now"
transparent: true
}
}
);

View File

@ -1,56 +1,76 @@
class CustomOfferList {
constructor(context, container, offerList) {
this.context = context;
this.container = container;
this.events =
new ourCoffeeSdk.util.EventEmitter();
this.offerList = null;
this.map = null;
this.onMarkerSelect = (markerId) => {
this.events.emit("offerSelect", {
offerId: markerId
});
};
this.setOfferList = ({
offerList: newOfferList
}) => {
if (this.map) {
this.map.destroy();
this.map = null;
class CustomOfferList extends ourCoffeeSdk.OfferListComponent {
constructor(
context,
container,
offerList,
options
) {
super(context, container, offerList, options);
this.onOfferButtonClickListener = (e) => {
const action = e.target.dataset.action;
const offerId = e.target.dataset.offerId;
if (action === "offerSelect") {
this.events.emit(action, { offerId });
} else if (action === "createOffer") {
const offer =
this.context.findOfferById(offerId);
if (offer) {
this.context.events.emit(
"createOrder",
{
offer
}
this.offerList = newOfferList;
if (newOfferList) {
this.map = new ourCoffeeSdk.DummyMapApi(
);
}
}
};
}
generateOfferHtml(offer) {
return ourCoffeeSdk.util.html`<li
class="custom-offer"
>
<aside><button
data-offer-id="${ourCoffeeSdk.util.attrValue(
offer.offerId
)}"
data-action="createOffer">Buy now for ${
offer.price.formattedValue
}</button><button
data-offer-id="${ourCoffeeSdk.util.attrValue(
offer.offerId
)}"
data-action="offerSelect">View details
</button></aside>
<div><strong>${offer.title}</strong></div>
<div>${offer.subtitle}</div>
<div>${offer.bottomLine}</div>
</li>`.toString();
}
setupDomListeners() {
const buttons =
this.container.querySelectorAll("button");
for (const button of buttons) {
button.addEventListener(
"click",
this.onOfferButtonClickListener
);
}
}
teardownDomListeners() {
const buttons = document.querySelectorAll(
this.container,
[
[16.355, 48.206],
[16.375, 48.214]
]
"button"
);
for (const offer of newOfferList) {
this.map.addMarker(
offer.offerId,
offer.location,
this.onMarkerSelect
for (const button of buttons) {
button.removeEventListener(
"click",
this.onOfferButtonClickListener
);
}
}
};
this.setOfferList({ offerList });
this.contextListener = context.events.on(
"offerPreviewListChange",
this.setOfferList
);
}
destroy() {
if (this.map) {
this.map.destroy();
}
this.contextListener.off();
}
}
class CustomComposer extends ourCoffeeSdk.SearchBoxComposer {
@ -72,19 +92,6 @@ class CustomComposer extends ourCoffeeSdk.SearchBoxComposer {
)
);
}
generateOfferPreviews(offerList) {
const result = super.generateOfferPreviews(
offerList
);
return result === null
? result
: result.map((preview, index) => ({
...preview,
location:
offerList[index].place.location
}));
}
}
class CustomSearchBox extends ourCoffeeSdk.SearchBox {

View File

@ -1,106 +1,189 @@
class CustomOfferList extends ourCoffeeSdk.OfferListComponent {
constructor(
context,
const {
SearchBox,
SearchBoxComposer,
OfferPanelComponent,
OfferPanelButton,
util,
dummyCoffeeApi
} = ourCoffeeSdk;
const { buildCreateOrderButton, buildCloseButton } =
OfferPanelComponent;
const buildCustomOrderButton = function (offer, container) {
return OfferPanelComponent.buildCreateOrderButton(
offer,
container,
{
createOrderButtonUrl:
offer && offer.createOrderButtonIcon,
createOrderButtonText:
(offer &&
`Buy now for just ${offer.price.formattedValue}`) ||
"Place an Order"
}
);
};
const buildCallButton = function (
offer,
container,
offerList,
options
) {
super(context, container, offerList, options);
this.onOfferButtonClickListener = (e) => {
const action = e.target.dataset.action;
const offerId = e.target.dataset.offerId;
if (action === "offerSelect") {
this.events.emit(action, { offerId });
} else if (action === "createOffer") {
const offer =
this.context.findOfferById(offerId);
if (offer) {
this.context.events.emit(
"createOrder",
{
offer
}
);
}
}
return new OfferPanelButton("call", container, {
text: util.html`<a href="tel:${util.attrValue(
offer.phone
)}" style="color: inherit; text-decoration: none;">${
options.callButtonText
}</a>`
});
};
}
generateOfferHtml(offer) {
return ourCoffeeSdk.util.html`<li
class="custom-offer"
>
<aside><button
data-offer-id="${ourCoffeeSdk.util.attrValue(
offer.offerId
)}"
data-action="createOffer">Buy now for ${
offer.price.formattedValue
}</button><button
data-offer-id="${ourCoffeeSdk.util.attrValue(
offer.offerId
)}"
data-action="offerSelect">View details
</button></aside>
<div><strong>${offer.title}</strong></div>
<div>${offer.subtitle}</div>
<div>${offer.bottomLine}</div>
</li>`.toString();
}
setupDomListeners() {
const buttons =
this.container.querySelectorAll("button");
for (const button of buttons) {
button.addEventListener(
"click",
this.onOfferButtonClickListener
const buildPreviousOfferButton = function (
offer,
container
) {
return new NavigateButton(
"left",
offer.previousOfferId,
container
);
};
const buildNextOfferButton = function (offer, container) {
return new NavigateButton(
"right",
offer.nextOfferId,
container
);
};
class NavigateButton {
constructor(direction, offerId, container) {
this.action = "navigate";
this.offerId = offerId;
this.events = new util.EventEmitter();
const button = (this.button =
document.createElement("button"));
button.innerHTML = direction === "left" ? "⟨" : "⟩";
button.className = direction;
container.classList.add("custom-control");
this.container = container;
this.listener = () =>
this.events.emit("press", {
target: this
});
button.addEventListener("click", this.listener);
container.appendChild(button);
}
destroy() {
this.button.removeEventListener("click", this.listener);
this.button.parentElement.removeChild(this.button);
this.container.classList.remove("custom-control");
}
}
teardownDomListeners() {
const buttons = document.querySelectorAll(
this.container,
"button"
);
for (const button of buttons) {
button.removeEventListener(
"click",
this.onOfferButtonClickListener
);
class CustomOfferPanel extends OfferPanelComponent {
show() {
const buttons = [];
const offer = this.currentOffer;
if (offer.previousOfferId) {
buttons.push(buildPreviousOfferButton);
}
buttons.push(buildCreateOrderButton);
if (offer.phone) {
buttons.push(buildCallButton);
}
buttons.push(buildCloseButton);
if (offer.nextOfferId) {
buttons.push(buildNextOfferButton);
}
this.options.buttonBuilders = buttons;
super.show();
}
}
class CustomComposer extends ourCoffeeSdk.SearchBoxComposer {
buildOfferListComponent(
class CustomComposer extends SearchBoxComposer {
buildOfferPanelComponent(
context,
container,
offerList,
currentOffer,
contextOptions
) {
return new CustomOfferList(
return new CustomOfferPanel(
context,
container,
this.generateOfferPreviews(
offerList,
this.generateCurrentOfferFullView(
currentOffer,
contextOptions
),
this.generateOfferListComponentOptions(
{
...CustomComposer.DEFAULT_OPTIONS,
...this.generateOfferPanelComponentOptions(
contextOptions
)
}
);
}
generateOfferPreviews(offerList) {
const result = super.generateOfferPreviews(offerList);
return result === null
? result
: result.map((preview, index) => ({
...preview,
imageUrl: offerList[index].place.icon
}));
}
generateCurrentOfferFullView(offer, options) {
const offerFullView =
super.generateCurrentOfferFullView(offer, options);
if (offer) {
if (offer.place.phone) {
offerFullView.phone = offer.place.phone;
}
if (offer.place.icon) {
offerFullView.createOrderButtonIcon =
offer.place.icon;
}
if (this.offerList) {
const offers = this.offerList;
const index = offers.findIndex(
({ offerId }) => offerId === offer.offerId
);
if (index > 0) {
offerFullView.previousOfferId =
offers[index - 1].offerId;
}
if (index < offers.length - 1 && index >= 0) {
offerFullView.nextOfferId =
offers[index + 1].offerId;
}
}
}
return offerFullView;
}
performAction(event) {
if (event.action === "navigate") {
this.selectOffer(event.target.offerId);
} else {
super.performAction(event);
}
}
}
class CustomSearchBox extends ourCoffeeSdk.SearchBox {
CustomComposer.DEFAULT_OPTIONS = {
createOrderButtonText: "🛒Place an Order",
callButtonText: "☎️ Make a Call",
closeButtonText: "❌Not Now"
};
class CustomSearchBox extends SearchBox {
buildComposer(context, container, options) {
return new CustomComposer(
context,
container,
options
);
return new CustomComposer(context, container, options);
}
createOrder(offer) {
@ -111,6 +194,6 @@ class CustomSearchBox extends ourCoffeeSdk.SearchBox {
const searchBox = new CustomSearchBox(
document.getElementById("search-box"),
ourCoffeeSdk.dummyCoffeeApi
dummyCoffeeApi
);
searchBox.search("Lungo");

View File

@ -1,105 +0,0 @@
const {
SearchBox,
SearchBoxComposer,
OfferPanelComponent,
OfferPanelButton,
util,
dummyCoffeeApi
} = ourCoffeeSdk;
const buildCallButton = function (
offer,
container,
options
) {
return new OfferPanelButton("call", container, {
text: util.html`<a href="tel:${util.attrValue(
offer.phone
)}" style="color: inherit; text-decoration: none;">${
options.callButtonText
}</a>`
});
};
const buildCreateOrderButton =
OfferPanelComponent.buildCreateOrderButton;
const buildCloseButton =
OfferPanelComponent.buildCloseButton;
class CustomOfferPanel extends OfferPanelComponent {
show() {
this.options.buttonBuilders = this
.currentOffer.phone
? [
buildCreateOrderButton,
buildCallButton,
buildCloseButton
]
: [
buildCreateOrderButton,
buildCloseButton
];
return super.show();
}
}
class CustomComposer extends SearchBoxComposer {
buildOfferPanelComponent(
context,
container,
currentOffer,
contextOptions
) {
return new CustomOfferPanel(
context,
container,
this.generateCurrentOfferFullView(
currentOffer,
contextOptions
),
this.generateOfferPanelComponentOptions(
contextOptions
)
);
}
generateCurrentOfferFullView(offer, options) {
const offerFullView =
super.generateCurrentOfferFullView(
offer,
options
);
if (offer && offer.place.phone) {
offerFullView.phone = offer.place.phone;
}
return offerFullView;
}
}
class CustomSearchBox extends SearchBox {
buildComposer(context, container, options) {
return new CustomComposer(
context,
container,
options
);
}
createOrder(offer) {
alert(`Isn't actually implemented (yet)`);
return super.createOrder(offer);
}
}
const searchBox = new CustomSearchBox(
document.getElementById("search-box"),
dummyCoffeeApi,
{
offerPanel: {
createOrderButtonText: "🛒Place an Order",
callButtonText: "☎️ Make a Call",
closeButtonText: "❌Not Now"
}
}
);
searchBox.search("Lungo");

View File

@ -18,7 +18,7 @@ export class OfferPanelButton implements IButton {
protected readonly options: IButtonOptions
) {
this.container.innerHTML = html`<button
class="our-coffee-sdk-sdk-offer-panel-button"
class="our-coffee-sdk-offer-panel-button"
>
${this.options.iconUrl
? html`<img src="${attrValue(this.options.iconUrl)}" />`
@ -27,7 +27,7 @@ export class OfferPanelButton implements IButton {
</button>`.toString();
this.button = $<HTMLButtonElement>(
this.container,
'.our-coffee-sdk-sdk-offer-panel-button'
'.our-coffee-sdk-offer-panel-button'
);
this.button.addEventListener('click', this.onClickListener, false);

View File

@ -97,7 +97,9 @@ export class OfferPanelComponent implements IOfferPanelComponent {
this.container,
'.our-coffee-sdk-offer-panel-buttons'
);
if (!this.options.transparent) {
this.container.classList.add('our-coffee-sdk-cover-blur');
}
this.setupButtons();
this.shown = true;
}
@ -150,11 +152,12 @@ export class OfferPanelComponent implements IOfferPanelComponent {
}
};
protected onButtonPress = ({ target: { action } }: IButtonPressEvent) => {
protected onButtonPress = ({ target }: IButtonPressEvent) => {
if (this.currentOffer !== null) {
this.events.emit('action', {
action,
offerId: this.currentOffer.offerId
action: target.action,
target,
currentOfferId: this.currentOffer.offerId
});
} else {
// TBD
@ -165,6 +168,7 @@ export class OfferPanelComponent implements IOfferPanelComponent {
export interface OfferPanelComponentOptions
extends IOfferPanelComponentOptions {
buttonBuilders?: ButtonBuilder[];
transparent?: boolean;
}
export type ButtonBuilder = (

View File

@ -87,6 +87,46 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
);
}
public selectOffer(offerId: string) {
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(
this.currentOffer,
this.contextOptions
)
});
} else {
// TDB
}
}
public performAction({
action,
currentOfferId: 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;
}
}
public destroy() {
for (const disposer of this.listenerDisposers) {
disposer.off();
@ -120,45 +160,12 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
});
};
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 onOfferPanelAction = (event: IOfferPanelActionEvent) => {
this.performAction(event);
};
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(
this.currentOffer,
this.contextOptions
)
});
} else {
// TDB
}
};
protected onOfferListOfferSelect = ({ offerId }: IOfferSelectedEvent) =>
this.selectOffer(offerId);
protected buildOfferListComponent(
context: ISearchBoxComposer,

View File

@ -18,5 +18,6 @@ export interface IOfferPanelComponentEvents {
export interface IOfferPanelActionEvent {
action: string;
offerId: string;
currentOfferId: string;
target?: any;
}

View File

@ -118,7 +118,7 @@
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 32px 30px;
padding: 0 32px;
margin: 2px 12px;
border: 1px solid #d8d8d8;
border-radius: 40px;
@ -149,9 +149,10 @@
list-style-type: none;
margin: 12px 0;
padding: 0;
position: relative;
}
.our-coffee-sdk-offer-panel-buttons button {
.our-coffee-sdk-offer-panel-button {
height: 50px;
width: 300px;
background: #3a3a3c;
@ -167,14 +168,13 @@
mix-blend-mode: luminosity;
}
.our-coffee-sdk-offer-panel-buttons button img {
.our-coffee-sdk-offer-panel-button img {
max-height: 22px;
max-width: 22px;
vertical-align: bottom;
}
.our-coffee-sdk-offer-panel-buttons
button.our-coffee-sdk-offer-panel-close-button {
.our-coffee-sdk-offer-panel-close-button {
font-size: 17px;
line-height: 22px;
height: 22px;
@ -189,7 +189,7 @@
font-size: 15px;
line-height: 34px;
letter-spacing: -0.24px;
margin: 5px 0;
margin: 5px 23px 0 0;
}
.custom-offer > aside {
@ -213,3 +213,31 @@
margin: 1px 0;
mix-blend-mode: luminosity;
}
.custom-control {
position: absolute;
top: 0;
height: 0;
width: 100%;
}
.custom-control > button {
color: rgba(60, 60, 67, 0.6);
font-size: 60px;
line-height: 30px;
margin-top: -15px;
height: 60px;
background: none;
cursor: pointer;
border: none;
}
.custom-control .right {
float: right;
margin-right: -30px;
}
.custom-control .left {
float: left;
margin-left: -30px;
}

View File

@ -137,8 +137,9 @@ describe('OfferPanelComponent', () => {
});
expect(events).toEqual([
{
offerId: CAFEE_CHAMOMILE_LUNGO_OFFER_FULL_VIEW.offerId,
action: MOCK_ACTION
currentOfferId: CAFEE_CHAMOMILE_LUNGO_OFFER_FULL_VIEW.offerId,
action: MOCK_ACTION,
target: mockButton
}
]);
offerPanel.destroy();

View File

@ -161,7 +161,7 @@ describe('SearchBoxComposer', () => {
'createOrder',
() => {
mockOfferPanel.events.emit('action', {
offerId: CAFEE_CHAMOMILE_LUNGO_OFFER.offerId,
currentOfferId: CAFEE_CHAMOMILE_LUNGO_OFFER.offerId,
action: 'createOrder'
});
}
@ -180,7 +180,7 @@ describe('SearchBoxComposer', () => {
event = e;
});
mockOfferPanel.events.emit('action', {
offerId: 'fakeOfferId',
currentOfferId: 'fakeOfferId',
action: 'createOrder'
});
expect(event).toEqual(null);
@ -200,7 +200,7 @@ describe('SearchBoxComposer', () => {
'offerFullViewToggle',
() => {
mockOfferPanel.events.emit('action', {
offerId: CAFEE_CHAMOMILE_LUNGO_OFFER.offerId,
currentOfferId: CAFEE_CHAMOMILE_LUNGO_OFFER.offerId,
action: 'close'
});
}

View File

@ -14,7 +14,8 @@ export class DummyMapApi {
'background-size: cover',
'position: relative',
'width: 100%',
'height: 100%'
'height: 100%',
'overflow: hidden'
].join(';');
}
@ -35,6 +36,7 @@ export class DummyMapApi {
(this.bounds[1][1] - this.bounds[0][1])
) - 30;
const marker = document.createElement('div');
marker.dataset.markerId = markerId;
marker.style.cssText = [
'position: absolute',
'width: 30px',
@ -44,7 +46,8 @@ export class DummyMapApi {
'align: center',
'line-height: 30px',
'font-size: 30px',
'cursor: pointer'
'cursor: pointer',
'mix-blend-mode: luminosity'
].join(';');
marker.innerHTML =
'<a href="javascript:void(0)" style="text-decoration: none;">📍</a>';
@ -58,6 +61,17 @@ export class DummyMapApi {
this.container.appendChild(marker);
}
public selectSingleMarker(markerId: string) {
for (const marker of this.container.childNodes) {
const element = <HTMLElement>marker;
if (element.dataset?.markerId === markerId) {
element.style.mixBlendMode = 'unset';
} else {
element.style.mixBlendMode = 'luminosity';
}
}
}
public destroy() {
for (const dispose of this.listenerDisposers) {
dispose();

View File

@ -2,7 +2,10 @@ const express = require('express');
const port = process.argv[2];
express()
.use('/', express.static('docs'))
.get('/', (req, res) => {
res.redirect('/The-API-Book');
})
.use('/The-API-Book', express.static('docs'))
.listen(port, () => {
console.log(`Docs Server is listening port ${port}`);
});

BIN
src/img/mockups/07.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -10,15 +10,19 @@
* иллюстрирует проблемы неоднозначности иерархий наследования и разделяемых ресурсов (иконки кнопки), как описано в предыдущей главе;
2. Замена списочного представления предложений, например, на представление в виде карты с метками:
* иллюстрирует проблему изменения дизайна одного субкомпонента (списка заказов) при сохранении поведения и дизайна остальных частей системы;
3. Добавление кнопки быстрого заказа в каждое предложение в списке:
* иллюстрирует проблему разделяемых ресурсов (см. более подробные пояснения ниже) и изменения UX системы в целом при сохранении существующего дизайна, поведения и бизнес-логики.
4. Добавление новой кнопки в панель создания заказа с дополнительной функцией (скажем, позвонить по телефону в кофейню):
* иллюстрирует проблему изменения бизнес-логики компонента при сохранении внешнего вида и UX компонента, а также неоднозначности иерархий наследования (поскольку новая кнопка должна располагать своими отдельными настройками).
[![APP](/img/mockups/05.png "Результаты поиска на карте")]()
3. Добавление кнопки быстрого заказа в каждое предложение в списке:
* иллюстрирует проблему разделяемых ресурсов (см. более подробные пояснения ниже) и изменения UX системы в целом при сохранении существующего дизайна, поведения и бизнес-логики.
[![APP](/img/mockups/06.png "Список результатов поиска с кнопками быстрых действий")]()
4. Динамическое добавление новой кнопки в панель создания заказа с дополнительной функцией (скажем, позвонить по телефону в кофейню):
* иллюстрирует проблему динамического изменения бизнес-логики компонента при сохранении внешнего вида и UX компонента, а также неоднозначности иерархий наследования (поскольку новая кнопка должна располагать своими отдельными настройками).
[![APP](/img/mockups/07.png "Дополнительная кнопка «Позвонить»")]()
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, которые можно будет кастомизировать под конкретную задачу. Например, мы можем реализовать связь между ними следующим образом:
```
@ -67,13 +71,13 @@ class SearchBox {
* единственный способ сделать заказ — клик внутри элемента «панель предложения»;
* заказ не может быть сделан, если предложение не было предварительно выбрано.
Из поставленных нами задач при такой декомпозиции мы можем условно решить только вторую (замена списка на карту), потому что к решению первой проблемы (замена иконки в кнопке заказа) мы не продвинулись вообще, а третью (кнопки быстрых действий в списке заказов) можно решить только следующим образом:
Из поставленных нами задач при такой декомпозиции мы можем условно решить только вторую (замена списка на карту), потому что к решению первой и четвёртой проблемы (настройка кнопок в панели) мы не продвинулись вообще, а третью (кнопки быстрых действий в списке заказов) можно решить только следующим образом:
* сделать панель заказа невидимой / перенести её за границы экрана;
* после события `"click"` на кнопке создания заказа дождаться окончания отрисовки невидимой панели и сгенерировать на ней фиктивное событие `"click"`.
Думаем, излишне уточнять, что подобные решения ни в каком случае не могут считать разумным API для UI-компонента. Но как же нам всё-таки *сделать* этот интерфейс расширяемым?
Первый очевидный шаг заключается в том, чтобы `SearchBox` перестал реагировать на низкоуровневые события типа `click`, а стал только лишь контекстом для нижележащих сущностей и работал в терминах своего уровня абстракции. А для этого нам нужно в первую очередь установить, что же он из себя представляет *логически*, какова его область ответственности как компонента?
Первая очевидная проблема заключается в том, что `SearchBox` должен реагировать на низкоуровневые события типа `click`. Согласно рекомендациям, данным нами в главе «[Слабая связность](#back-compat-weak-coupling)», мы должны сделать его контекстом для нижележащих сущностей, а для этого нам нужно в первую очередь установить, что же он из себя представляет *логически*, какова его область ответственности как компонента?
Предположим, что мы определим `SearchBox` концептуально как конечный автомат, находящийся в одном из трёх состояний:
1. Пуст (ожидает запроса пользователя и получения списка предложений).