Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 24ada26

Browse files
authoredJan 3, 2022
refactor: use vanilla custom elements for tabs (#136)
1 parent a4c4969 commit 24ada26

File tree

10 files changed

+245
-273
lines changed

10 files changed

+245
-273
lines changed
 

‎.changeset/fifty-toes-confess.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@api-viewer/common': patch
3+
'@api-viewer/demo': patch
4+
'@api-viewer/docs': patch
5+
'@api-viewer/tabs': patch
6+
'api-viewer-element': patch
7+
---
8+
9+
Use vanilla custom elements for tabs

‎.eslintrc.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"import/no-unresolved": "off",
2525
"import/prefer-default-export": "off",
2626
"lit/no-template-map": "off",
27+
"lit/prefer-static-styles": "off",
2728
"class-methods-use-this": [
2829
"error",
2930
{
@@ -33,7 +34,8 @@
3334
"no-console": ["error", { "allow": ["error"] }],
3435
"no-param-reassign": "off",
3536
"no-plusplus": "off",
36-
"no-underscore-dangle": "off"
37+
"no-underscore-dangle": "off",
38+
"prefer-destructuring": "off"
3739
},
3840
"overrides": [
3941
{

‎packages/api-common/src/utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,12 @@ export const unquote = (value?: string): string | undefined =>
22
typeof value === 'string' && value.startsWith("'") && value.endsWith("'")
33
? value.slice(1, value.length - 1)
44
: value;
5+
6+
export function html(strings: TemplateStringsArray, ...values: string[]) {
7+
const template = document.createElement('template');
8+
template.innerHTML = values.reduce(
9+
(acc, v, idx) => acc + v + strings[idx + 1],
10+
strings[0]
11+
);
12+
return template;
13+
}

‎packages/api-demo/src/layout.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,14 @@ class ApiDemoLayout extends LitElement {
102102
const slots = this.slotsController?.data || [];
103103
const cssProps = this.stylesController?.data || [];
104104
const hideSlots = noSlots || hasTemplate(id, tag, TemplateTypes.SLOT);
105+
const hideKnobs = noProps && noCustomKnobs;
105106

106107
return html`
107108
<div part="demo-output" @rendered=${this.onRendered}>
108109
${renderer({ id, tag, knobs: this.knobs })}
109110
</div>
110111
<api-viewer-tabs part="demo-tabs">
111-
<api-viewer-tab heading="Source" slot="tab" part="tab"></api-viewer-tab>
112+
<api-viewer-tab slot="tab" part="tab">Source</api-viewer-tab>
112113
<api-viewer-panel slot="panel" part="tab-panel">
113114
<button @click=${this._onCopyClick} part="button">
114115
${this.copyBtnText}
@@ -117,15 +118,16 @@ class ApiDemoLayout extends LitElement {
117118
${renderSnippet(id, tag, this.knobs, slots, cssProps)}
118119
</div>
119120
</api-viewer-panel>
120-
<api-viewer-tab
121-
heading="Knobs"
122-
slot="tab"
123-
part="tab"
124-
?hidden=${noProps && noCustomKnobs && hideSlots}
125-
></api-viewer-tab>
121+
<api-viewer-tab slot="tab" part="tab" ?hidden=${hideKnobs && hideSlots}>
122+
Knobs
123+
</api-viewer-tab>
126124
<api-viewer-panel slot="panel" part="tab-panel">
127125
<div part="knobs">
128-
<section part="knobs-column" @change=${this._onPropChanged}>
126+
<section
127+
?hidden=${hideKnobs}
128+
part="knobs-column"
129+
@change=${this._onPropChanged}
130+
>
129131
${renderKnobs(this.propKnobs, 'Properties', 'prop', propRenderer)}
130132
${renderKnobs(
131133
this.customKnobs,
@@ -143,12 +145,9 @@ class ApiDemoLayout extends LitElement {
143145
</section>
144146
</div>
145147
</api-viewer-panel>
146-
<api-viewer-tab
147-
heading="Styles"
148-
slot="tab"
149-
part="tab"
150-
?hidden=${noCss}
151-
></api-viewer-tab>
148+
<api-viewer-tab slot="tab" part="tab" ?hidden=${noCss}>
149+
Styles
150+
</api-viewer-tab>
152151
<api-viewer-panel slot="panel" part="tab-panel">
153152
<div part="knobs" ?hidden=${noCss}>
154153
<section part="knobs-column" @change=${this._onCssChanged}>
@@ -161,13 +160,9 @@ class ApiDemoLayout extends LitElement {
161160
</section>
162161
</div>
163162
</api-viewer-panel>
164-
<api-viewer-tab
165-
id="events"
166-
heading="Events"
167-
slot="tab"
168-
part="tab"
169-
?hidden=${noEvents}
170-
></api-viewer-tab>
163+
<api-viewer-tab id="events" slot="tab" part="tab" ?hidden=${noEvents}>
164+
Events
165+
</api-viewer-tab>
171166
<api-viewer-panel slot="panel" part="tab-panel">
172167
<div part="event-log" ?hidden=${noEvents}>
173168
<button

‎packages/api-docs/src/layout.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,9 @@ const renderTab = (
6565
): TemplateResult => {
6666
const hidden = array.length === 0;
6767
return html`
68-
<api-viewer-tab
69-
heading=${heading}
70-
slot="tab"
71-
part="tab"
72-
?hidden=${hidden}
73-
></api-viewer-tab>
68+
<api-viewer-tab slot="tab" part="tab" ?hidden=${hidden}>
69+
${heading}
70+
</api-viewer-tab>
7471
<api-viewer-panel slot="panel" part="tab-panel" ?hidden=${hidden}>
7572
${content}
7673
</api-viewer-panel>

‎packages/api-tabs/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@
2727
"web-components"
2828
],
2929
"dependencies": {
30-
"lit": "^2.0.0",
31-
"tslib": "^2.3.1"
30+
"@api-viewer/common": "^1.0.0-pre.2"
3231
},
3332
"contributors": [
3433
{

‎packages/api-tabs/src/api-viewer-panel.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,32 @@
1-
import { LitElement, html, css, TemplateResult } from 'lit';
1+
import { html } from '@api-viewer/common/lib/utils.js';
22

33
let panelIdCounter = 0;
44

5-
export class ApiViewerPanel extends LitElement {
6-
static get styles() {
7-
return css`
8-
:host {
9-
display: block;
10-
box-sizing: border-box;
11-
width: 100%;
12-
overflow: hidden;
13-
}
14-
15-
:host([hidden]) {
16-
display: none !important;
17-
}
18-
`;
19-
}
5+
const tpl = html`
6+
<style>
7+
:host {
8+
display: block;
9+
box-sizing: border-box;
10+
width: 100%;
11+
overflow: hidden;
12+
}
13+
14+
:host([hidden]) {
15+
display: none !important;
16+
}
17+
</style>
18+
<slot></slot>
19+
`;
20+
21+
export class ApiViewerPanel extends HTMLElement {
22+
constructor() {
23+
super();
2024

21-
protected render(): TemplateResult {
22-
return html`<slot></slot>`;
25+
const root = this.attachShadow({ mode: 'open' });
26+
root.appendChild(tpl.content.cloneNode(true));
2327
}
2428

25-
protected firstUpdated(): void {
29+
connectedCallback(): void {
2630
this.setAttribute('role', 'tabpanel');
2731

2832
if (!this.id) {

‎packages/api-tabs/src/api-viewer-tab.ts

Lines changed: 98 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,115 +1,119 @@
1-
import { LitElement, html, css, PropertyValues, TemplateResult } from 'lit';
2-
import { property } from 'lit/decorators/property.js';
1+
import { html } from '@api-viewer/common/lib/utils.js';
32

43
let tabIdCounter = 0;
54

6-
export class ApiViewerTab extends LitElement {
7-
@property({ type: Boolean, reflect: true }) selected = false;
5+
const tpl = html`
6+
<style>
7+
:host {
8+
display: flex;
9+
align-items: center;
10+
flex-shrink: 0;
11+
box-sizing: border-box;
12+
position: relative;
13+
max-width: 150px;
14+
overflow: hidden;
15+
min-height: 3rem;
16+
padding: 0 1rem;
17+
color: var(--ave-tab-color);
18+
font-size: 0.875rem;
19+
line-height: 1.2;
20+
font-weight: 500;
21+
text-transform: uppercase;
22+
outline: none;
23+
cursor: pointer;
24+
-webkit-user-select: none;
25+
user-select: none;
26+
-webkit-tap-highlight-color: transparent;
27+
}
828
9-
@property() heading = '';
29+
:host([hidden]) {
30+
display: none !important;
31+
}
1032
11-
@property({ type: Boolean }) active = false;
33+
:host::before {
34+
content: '';
35+
display: block;
36+
position: absolute;
37+
top: 0;
38+
left: 0;
39+
bottom: 0;
40+
width: var(--ave-tab-indicator-size);
41+
background-color: var(--ave-primary-color);
42+
opacity: 0;
43+
}
1244
13-
private _mousedown = false;
45+
:host([selected]) {
46+
color: var(--ave-tab-selected-color, var(--ave-primary-color));
47+
}
1448
15-
static get styles() {
16-
return css`
17-
:host {
18-
display: flex;
19-
align-items: center;
20-
flex-shrink: 0;
21-
box-sizing: border-box;
22-
position: relative;
23-
max-width: 150px;
24-
overflow: hidden;
25-
min-height: 3rem;
26-
padding: 0 1rem;
27-
color: var(--ave-tab-color);
28-
font-size: 0.875rem;
29-
line-height: 1.2;
30-
font-weight: 500;
31-
text-transform: uppercase;
32-
outline: none;
33-
cursor: pointer;
34-
-webkit-user-select: none;
35-
user-select: none;
36-
-webkit-tap-highlight-color: transparent;
37-
}
49+
:host([selected])::before {
50+
opacity: 1;
51+
}
3852
39-
:host([hidden]) {
40-
display: none !important;
41-
}
53+
:host::after {
54+
content: '';
55+
position: absolute;
56+
top: 0;
57+
right: 0;
58+
bottom: 0;
59+
left: 0;
60+
background-color: var(--ave-primary-color);
61+
opacity: 0;
62+
transition: opacity 0.1s linear;
63+
}
4264
43-
:host::before {
44-
content: '';
45-
display: block;
46-
position: absolute;
47-
top: 0;
48-
left: 0;
49-
bottom: 0;
50-
width: var(--ave-tab-indicator-size);
51-
background-color: var(--ave-primary-color);
52-
opacity: 0;
53-
}
65+
:host(:hover)::after {
66+
opacity: 0.08;
67+
}
5468
55-
:host([selected]) {
56-
color: var(--ave-tab-selected-color, var(--ave-primary-color));
57-
}
69+
:host([focus-ring])::after {
70+
opacity: 0.12;
71+
}
5872
59-
:host([selected])::before {
60-
opacity: 1;
61-
}
73+
:host([active])::after {
74+
opacity: 0.16;
75+
}
6276
63-
:host::after {
64-
content: '';
65-
position: absolute;
66-
top: 0;
67-
right: 0;
68-
bottom: 0;
69-
left: 0;
70-
background-color: var(--ave-primary-color);
71-
opacity: 0;
72-
transition: opacity 0.1s linear;
77+
@media (max-width: 600px) {
78+
:host {
79+
justify-content: center;
80+
text-align: center;
7381
}
7482
75-
:host(:hover)::after {
76-
opacity: 0.08;
83+
:host::before {
84+
top: auto;
85+
right: 0;
86+
width: 100%;
87+
height: var(--ave-tab-indicator-size);
7788
}
89+
}
90+
</style>
91+
<slot></slot>
92+
`;
7893

79-
:host([focus-ring])::after {
80-
opacity: 0.12;
81-
}
94+
export class ApiViewerTab extends HTMLElement {
95+
private _mousedown = false;
8296

83-
:host([active])::after {
84-
opacity: 0.16;
85-
}
97+
private _selected = false;
8698

87-
@media (max-width: 600px) {
88-
:host {
89-
justify-content: center;
90-
text-align: center;
91-
}
92-
93-
:host::before {
94-
top: auto;
95-
right: 0;
96-
width: 100%;
97-
height: var(--ave-tab-indicator-size);
98-
}
99-
}
100-
`;
99+
get selected(): boolean {
100+
return this._selected;
101101
}
102102

103-
protected render(): TemplateResult {
104-
return html`${this.heading}`;
103+
set selected(selected: boolean) {
104+
this._selected = selected;
105+
106+
this.setAttribute('aria-selected', String(selected));
107+
this.setAttribute('tabindex', selected ? '0' : '-1');
108+
109+
this.toggleAttribute('selected', selected);
105110
}
106111

107-
protected firstUpdated(): void {
108-
this.setAttribute('role', 'tab');
112+
constructor() {
113+
super();
109114

110-
if (!this.id) {
111-
this.id = `api-viewer-tab-${tabIdCounter++}`;
112-
}
115+
const root = this.attachShadow({ mode: 'open' });
116+
root.appendChild(tpl.content.cloneNode(true));
113117

114118
this.addEventListener('focus', () => this._setFocused(true), true);
115119
this.addEventListener(
@@ -131,10 +135,11 @@ export class ApiViewerTab extends LitElement {
131135
});
132136
}
133137

134-
protected updated(props: PropertyValues): void {
135-
if (props.has('selected')) {
136-
this.setAttribute('aria-selected', String(this.selected));
137-
this.setAttribute('tabindex', this.selected ? '0' : '-1');
138+
connectedCallback(): void {
139+
this.setAttribute('role', 'tab');
140+
141+
if (!this.id) {
142+
this.id = `api-viewer-tab-${tabIdCounter++}`;
138143
}
139144
}
140145

‎packages/api-tabs/src/api-viewer-tabs.ts

Lines changed: 78 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,62 @@
1-
import { LitElement, html, css, TemplateResult } from 'lit';
1+
import { html } from '@api-viewer/common/lib/utils.js';
22
import { ApiViewerTab } from './api-viewer-tab.js';
33
import type { ApiViewerPanel } from './api-viewer-panel.js';
44
import './api-viewer-panel.js';
55

6-
const KEYCODE = {
7-
DOWN: 40,
8-
LEFT: 37,
9-
RIGHT: 39,
10-
UP: 38,
11-
HOME: 36,
12-
END: 35
13-
};
14-
15-
export class ApiViewerTabs extends LitElement {
16-
private _boundSlotChange = this._onSlotChange.bind(this);
6+
const tpl = html`
7+
<style>
8+
:host {
9+
display: flex;
10+
border-bottom-left-radius: var(--ave-border-radius);
11+
overflow: hidden;
12+
}
1713
18-
static get styles() {
19-
return css`
14+
@media (max-width: 600px) {
2015
:host {
21-
display: flex;
22-
border-bottom-left-radius: var(--ave-border-radius);
23-
overflow: hidden;
16+
flex-direction: column;
2417
}
2518
2619
.tabs {
27-
display: block;
20+
display: flex;
21+
flex-grow: 1;
22+
align-self: stretch;
23+
overflow-x: auto;
24+
-webkit-overflow-scrolling: touch;
2825
}
26+
}
27+
</style>
28+
<div class="tabs">
29+
<slot name="tab"></slot>
30+
</div>
31+
<slot name="panel"></slot>
32+
`;
2933

30-
@media (max-width: 600px) {
31-
:host {
32-
flex-direction: column;
33-
}
34-
35-
.tabs {
36-
flex-grow: 1;
37-
display: flex;
38-
align-self: stretch;
39-
overflow-x: auto;
40-
-webkit-overflow-scrolling: touch;
41-
}
42-
}
43-
`;
44-
}
34+
export class ApiViewerTabs extends HTMLElement {
35+
constructor() {
36+
super();
4537

46-
render(): TemplateResult {
47-
return html`
48-
<div class="tabs">
49-
<slot name="tab"></slot>
50-
</div>
51-
<slot name="panel"></slot>
52-
`;
53-
}
38+
const root = this.attachShadow({ mode: 'open' });
39+
root.appendChild(tpl.content.cloneNode(true));
5440

55-
firstUpdated(): void {
56-
this.setAttribute('role', 'tablist');
41+
const slots = root.querySelectorAll('slot');
5742

58-
this.addEventListener('keydown', this._onKeyDown);
59-
this.addEventListener('click', this._onClick);
43+
slots[0].addEventListener('slotchange', () => this._linkPanels());
44+
slots[1].addEventListener('slotchange', () => this._linkPanels());
6045

61-
const [tabSlot, panelSlot] = Array.from(
62-
this.renderRoot.querySelectorAll('slot')
63-
);
46+
this.addEventListener('keydown', this.handleEvent);
47+
this.addEventListener('click', this.handleEvent);
48+
}
6449

65-
if (tabSlot && panelSlot) {
66-
tabSlot.addEventListener('slotchange', this._boundSlotChange);
67-
panelSlot.addEventListener('slotchange', this._boundSlotChange);
68-
}
50+
connectedCallback(): void {
51+
this.setAttribute('role', 'tablist');
6952

70-
Promise.all(
71-
[...this._allTabs(), ...this._allPanels()].map((el) => el.updateComplete)
72-
).then(() => {
53+
requestAnimationFrame(() => {
7354
this._linkPanels();
7455
});
7556
}
7657

77-
private _onSlotChange(): void {
78-
this._linkPanels();
79-
}
80-
8158
private _linkPanels(): void {
82-
const tabs = this._allTabs();
59+
const { tabs } = this;
8360
tabs.forEach((tab) => {
8461
const panel = tab.nextElementSibling as ApiViewerPanel;
8562
tab.setAttribute('aria-controls', panel.id);
@@ -91,16 +68,12 @@ export class ApiViewerTabs extends LitElement {
9168
this._selectTab(selectedTab);
9269
}
9370

94-
private _allPanels(): ApiViewerPanel[] {
95-
return Array.from(this.querySelectorAll('api-viewer-panel'));
96-
}
97-
98-
private _allTabs(): ApiViewerTab[] {
71+
get tabs(): ApiViewerTab[] {
9972
return Array.from(this.querySelectorAll('api-viewer-tab'));
10073
}
10174

10275
private _getAvailableIndex(idx: number, increment: number): number {
103-
const tabs = this._allTabs();
76+
const { tabs } = this;
10477
const total = tabs.length;
10578
for (
10679
let i = 0;
@@ -120,32 +93,15 @@ export class ApiViewerTabs extends LitElement {
12093
return -1;
12194
}
12295

123-
private _panelForTab(tab: ApiViewerTab): ApiViewerPanel | null {
124-
const panelId = tab.getAttribute('aria-controls');
125-
return this.querySelector(`#${panelId}`);
126-
}
127-
128-
private _prevTab(): ApiViewerTab {
129-
const tabs = this._allTabs();
96+
private _prevTab(tabs: ApiViewerTab[]): ApiViewerTab {
13097
const newIdx = this._getAvailableIndex(
13198
tabs.findIndex((tab) => tab.selected) - 1,
13299
-1
133100
);
134101
return tabs[(newIdx + tabs.length) % tabs.length];
135102
}
136103

137-
private _firstTab(): ApiViewerTab {
138-
const tabs = this._allTabs();
139-
return tabs[0];
140-
}
141-
142-
private _lastTab(): ApiViewerTab {
143-
const tabs = this._allTabs();
144-
return tabs[tabs.length - 1];
145-
}
146-
147-
private _nextTab(): ApiViewerTab {
148-
const tabs = this._allTabs();
104+
private _nextTab(tabs: ApiViewerTab[]): ApiViewerTab {
149105
const newIdx = this._getAvailableIndex(
150106
tabs.findIndex((tab) => tab.selected) + 1,
151107
1
@@ -157,14 +113,11 @@ export class ApiViewerTabs extends LitElement {
157113
* `reset()` marks all tabs as deselected and hides all the panels.
158114
*/
159115
public reset(): void {
160-
const tabs = this._allTabs();
161-
const panels = this._allPanels();
162-
163-
tabs.forEach((tab) => {
116+
this.tabs.forEach((tab) => {
164117
tab.selected = false;
165118
});
166119

167-
panels.forEach((panel) => {
120+
this.querySelectorAll('api-viewer-panel').forEach((panel) => {
168121
panel.hidden = true;
169122
});
170123
}
@@ -173,61 +126,56 @@ export class ApiViewerTabs extends LitElement {
173126
* `selectFirst()` automatically selects first non-hidden tab.
174127
*/
175128
public selectFirst(): void {
176-
const tabs = this._allTabs();
177129
const idx = this._getAvailableIndex(0, 1);
178-
this._selectTab(tabs[idx % tabs.length]);
130+
this._selectTab(this.tabs[idx % this.tabs.length]);
179131
}
180132

181133
private _selectTab(newTab: ApiViewerTab): void {
182134
this.reset();
183135

184-
const newPanel = this._panelForTab(newTab);
136+
const panelId = newTab.getAttribute('aria-controls');
137+
const newPanel = this.querySelector(`#${panelId}`) as ApiViewerPanel;
185138
if (newPanel) {
186139
newTab.selected = true;
187140
newPanel.hidden = false;
188141
}
189142
}
190143

191-
private _onKeyDown(event: KeyboardEvent): void {
144+
handleEvent(event: KeyboardEvent | MouseEvent): void {
192145
const { target } = event;
193-
if ((target && target instanceof ApiViewerTab) === false) {
194-
return;
195-
}
196-
197-
if (event.altKey) {
198-
return;
199-
}
200-
201-
let newTab;
202-
switch (event.keyCode) {
203-
case KEYCODE.LEFT:
204-
case KEYCODE.UP:
205-
newTab = this._prevTab();
206-
break;
207-
case KEYCODE.RIGHT:
208-
case KEYCODE.DOWN:
209-
newTab = this._nextTab();
210-
break;
211-
case KEYCODE.HOME:
212-
newTab = this._firstTab();
213-
break;
214-
case KEYCODE.END:
215-
newTab = this._lastTab();
216-
break;
217-
default:
218-
return;
219-
}
146+
if (target && target instanceof ApiViewerTab) {
147+
let newTab: ApiViewerTab;
148+
149+
if (event.type === 'keydown') {
150+
const { tabs } = this;
151+
152+
switch ((event as KeyboardEvent).key) {
153+
case 'ArrowLeft':
154+
case 'ArrowUp':
155+
newTab = this._prevTab(tabs);
156+
break;
157+
case 'ArrowDown':
158+
case 'ArrowRight':
159+
newTab = this._nextTab(tabs);
160+
break;
161+
case 'Home':
162+
newTab = tabs[0];
163+
break;
164+
case 'End':
165+
newTab = tabs[tabs.length - 1];
166+
break;
167+
default:
168+
// Return to not prevent default.
169+
return;
170+
}
220171

221-
event.preventDefault();
222-
this._selectTab(newTab);
223-
newTab.focus();
224-
}
172+
event.preventDefault();
173+
} else {
174+
newTab = target;
175+
}
225176

226-
private _onClick(event: MouseEvent): void {
227-
const { target } = event;
228-
if (target && target instanceof ApiViewerTab) {
229-
this._selectTab(target);
230-
target.focus();
177+
this._selectTab(newTab);
178+
newTab.focus();
231179
}
232180
}
233181
}

‎packages/api-tabs/tsconfig.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
"rootDir": "./src",
99
"composite": true
1010
},
11-
"references": [],
11+
"references": [
12+
{
13+
"path": "../api-common/tsconfig.json"
14+
}
15+
],
1216
"include": [
1317
"src"
1418
],

0 commit comments

Comments
 (0)
Please sign in to comment.