Skip to content

Commit 3993bbd

Browse files
author
Jakob Vogelsang
committed
feat(editor-container): add initial web-component
1 parent 06a30fc commit 3993bbd

File tree

2 files changed

+358
-0
lines changed

2 files changed

+358
-0
lines changed

src/editor-container.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { IconButton } from '@material/mwc-icon-button';
2+
import { ListItem } from '@material/mwc-list/mwc-list-item';
3+
import { Menu } from '@material/mwc-menu';
4+
import {
5+
css,
6+
customElement,
7+
html,
8+
LitElement,
9+
property,
10+
query,
11+
TemplateResult,
12+
} from 'lit-element';
13+
import { newWizardEvent, SCLTag, tags, Wizard } from './foundation.js';
14+
import { emptyWizard, wizards } from './wizards/wizard-library.js';
15+
16+
@customElement('editor-container')
17+
export class EditorContainer extends LitElement {
18+
@property({ type: String })
19+
header = '';
20+
@property({ type: Array })
21+
addOptions: string[] = [];
22+
@property({ type: String })
23+
level: 'high' | 'mid' | 'low' = 'mid';
24+
@property({ type: String })
25+
colorTheme: 'primary' | 'secondary' = 'primary';
26+
@property({ type: Boolean })
27+
highlighted = false;
28+
29+
@property()
30+
addElementAction: (tagName: string) => Wizard | undefined = () => {
31+
return undefined;
32+
};
33+
34+
@query('mwc-icon-button[icon="playlist_add"]') addIcon?: IconButton;
35+
@query('#menu') addMenu!: Menu;
36+
@query('#header') headerContainer!: HTMLElement;
37+
@query('#more') moreVert?: IconButton;
38+
39+
renderAddButtons(): TemplateResult[] {
40+
return this.addOptions.map(
41+
child =>
42+
html`<mwc-list-item graphic="icon" value="${child}"
43+
><span>${child}</span
44+
><mwc-icon slot="graphic">playlist_add</mwc-icon></mwc-list-item
45+
>`
46+
);
47+
}
48+
49+
styleFabButtonTransform(): TemplateResult[] {
50+
let transform = 0;
51+
return Array.from(this.children).map((child, i) => {
52+
if (child.tagName === 'MWC-FAB')
53+
return html`#more:focus-within ~ ::slotted(mwc-fab:nth-child(${i + 1}))
54+
{ transform: translate(0, ${++transform * 48}px); }`;
55+
return html``;
56+
});
57+
}
58+
59+
renderHeaderBody(): TemplateResult {
60+
return html`${this.addOptions.length
61+
? html`<mwc-icon-button
62+
icon="playlist_add"
63+
@click=${() => (this.addMenu.open = true)}
64+
></mwc-icon-button>
65+
<mwc-menu
66+
id="menu"
67+
corner="TOP_RIGHT"
68+
menuCorner="END"
69+
@selected=${(e: Event) => {
70+
const tagName = (<ListItem>(<Menu>e.target).selected).value;
71+
const wizard = this.addElementAction(tagName);
72+
if (wizard) this.dispatchEvent(newWizardEvent(wizard));
73+
}}
74+
>${this.renderAddButtons()}
75+
</mwc-menu>`
76+
: html``}
77+
${Array.from(this.children).some(child => child.tagName === 'MWC-FAB')
78+
? html`<mwc-icon-button id="more" icon="more_vert"></mwc-icon-button>`
79+
: html``}<slot
80+
><style>
81+
82+
${this.addOptions.length
83+
? html`::slotted(mwc-fab) {right: 48px;}`
84+
: html`::slotted(mwc-fab) {right: 0px;}`}
85+
${this.styleFabButtonTransform()}
86+
</style></slot
87+
>`;
88+
}
89+
90+
renderLevel1(): TemplateResult {
91+
return html`<h1>${this.header} ${this.renderHeaderBody()}</h1>`;
92+
}
93+
94+
renderLevel2(): TemplateResult {
95+
return html`<h2>${this.header} ${this.renderHeaderBody()}</h2>`;
96+
}
97+
98+
renderLevel3(): TemplateResult {
99+
return html`<h3>${this.header} ${this.renderHeaderBody()}</h3>`;
100+
}
101+
102+
renderHeader(): TemplateResult {
103+
return html`<div id="header">
104+
${this.level === 'high'
105+
? this.renderLevel1()
106+
: this.level === 'mid'
107+
? this.renderLevel2()
108+
: this.renderLevel3()}
109+
</div>`;
110+
}
111+
112+
render(): TemplateResult {
113+
return html`<section
114+
class="container ${this.colorTheme} ${this.highlighted
115+
? 'highlighted'
116+
: ''}"
117+
tabindex="0"
118+
>
119+
${this.renderHeader()}
120+
</section>`;
121+
}
122+
123+
async firstUpdated(): Promise<void> {
124+
await super.updateComplete;
125+
if (this.addMenu) this.addMenu.anchor = this.headerContainer;
126+
}
127+
128+
static styles = css`
129+
:host(.moving) section {
130+
opacity: 0.3;
131+
}
132+
133+
.container {
134+
background-color: var(--mdc-theme-surface);
135+
transition: all 200ms linear;
136+
outline-style: solid;
137+
margin: 8px 12px 16px;
138+
opacity: 1;
139+
}
140+
141+
.container.primary {
142+
outline-color: var(--mdc-theme-primary);
143+
}
144+
145+
.container.secondary {
146+
outline-color: var(--mdc-theme-secondary);
147+
}
148+
149+
.highlighted {
150+
outline-width: 2px;
151+
}
152+
153+
.container:focus {
154+
box-shadow: 0 8px 10px 1px rgba(0, 0, 0, 0.14),
155+
0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.2);
156+
}
157+
158+
.container:focus-within {
159+
outline-width: 2px;
160+
transition: all 250ms linear;
161+
}
162+
163+
.container:focus-within h1,
164+
.container:focus-within h2,
165+
.container:focus-within h3 {
166+
color: var(--mdc-theme-surface);
167+
transition: background-color 200ms linear;
168+
}
169+
170+
.container.primary:focus-within h1,
171+
.container.primary:focus-within h2,
172+
.container.primary:focus-within h3 {
173+
background-color: var(--mdc-theme-primary);
174+
}
175+
176+
.container.secondary:focus-within h1,
177+
.container.secondary:focus-within h2,
178+
.container.secondary:focus-within h3 {
179+
background-color: var(--mdc-theme-secondary);
180+
}
181+
182+
h1,
183+
h2,
184+
h3 {
185+
color: var(--mdc-theme-on-surface);
186+
font-family: 'Roboto', sans-serif;
187+
font-weight: 300;
188+
overflow: hidden;
189+
white-space: nowrap;
190+
text-overflow: ellipsis;
191+
margin: 0px;
192+
line-height: 48px;
193+
padding-left: 0.3em;
194+
transition: background-color 150ms linear;
195+
}
196+
197+
h1 > ::slotted(mwc-icon-button),
198+
h2 > ::slotted(mwc-icon-button),
199+
h3 > ::slotted(mwc-icon-button),
200+
h1 > ::slotted(abbr),
201+
h2 > ::slotted(abbr),
202+
h3 > ::slotted(abbr) {
203+
float: right;
204+
}
205+
206+
h1 > mwc-icon-button,
207+
h2 > mwc-icon-button,
208+
h3 > mwc-icon-button {
209+
float: right;
210+
}
211+
212+
#header {
213+
position: relative;
214+
}
215+
216+
abbr {
217+
text-decoration: none;
218+
border-bottom: none;
219+
}
220+
221+
::slotted(mwc-fab) {
222+
color: var(--mdc-theme-on-surface);
223+
opacity: 0;
224+
position: absolute;
225+
pointer-events: none;
226+
z-index: 1;
227+
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1),
228+
opacity 200ms linear;
229+
}
230+
231+
#more:focus-within ~ ::slotted(mwc-fab) {
232+
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1),
233+
opacity 250ms linear;
234+
pointer-events: auto;
235+
opacity: 1;
236+
}
237+
`;
238+
}

test/unit/editor-container.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { expect, fixture, html } from '@open-wc/testing';
2+
import sinon, { SinonSpy } from 'sinon';
3+
4+
import '../../src/editor-container.js';
5+
import { EditorContainer } from '../../src/editor-container.js';
6+
7+
describe('editor-container', () => {
8+
let element: EditorContainer;
9+
beforeEach(async () => {
10+
element = await fixture(
11+
html`<editor-container header="test header"></editor-container>`
12+
);
13+
await element.updateComplete;
14+
});
15+
16+
it('does not render more vert option with missing mwc-fab children', () => {
17+
expect(element.moreVert).to.not.exist;
18+
});
19+
20+
it('renders more vert option with existing mwc-fab children', async () => {
21+
const fabChild = element.ownerDocument.createElement('mwc-fab');
22+
element.appendChild(fabChild);
23+
await element.requestUpdate();
24+
expect(element.moreVert).to.exist;
25+
});
26+
27+
it('renders the header as <h2> per default', () => {
28+
expect(element.shadowRoot?.querySelector('h2')).to.exist;
29+
expect(element.shadowRoot?.querySelector('h1')).to.not.exist;
30+
expect(element.shadowRoot?.querySelector('h3')).to.not.exist;
31+
});
32+
33+
it('renders the header as <h1> with level high', async () => {
34+
element.level = 'high';
35+
await element.updateComplete;
36+
expect(element.shadowRoot?.querySelector('h1')).to.exist;
37+
expect(element.shadowRoot?.querySelector('h2')).to.not.exist;
38+
expect(element.shadowRoot?.querySelector('h3')).to.not.exist;
39+
});
40+
41+
it('renders the header as <h2> with level mid', async () => {
42+
element.level = 'mid';
43+
await element.updateComplete;
44+
expect(element.shadowRoot?.querySelector('h2')).to.exist;
45+
expect(element.shadowRoot?.querySelector('h1')).to.not.exist;
46+
expect(element.shadowRoot?.querySelector('h3')).to.not.exist;
47+
});
48+
49+
it('renders the header as <h3> with level low', async () => {
50+
element.level = 'low';
51+
await element.updateComplete;
52+
expect(element.shadowRoot?.querySelector('h3')).to.exist;
53+
expect(element.shadowRoot?.querySelector('h1')).to.not.exist;
54+
expect(element.shadowRoot?.querySelector('h2')).to.not.exist;
55+
});
56+
57+
it('does not render add icon and add menu with missing addOptions', () => {
58+
expect(element.addMenu).to.not.exist;
59+
expect(element.addIcon).to.not.exist;
60+
});
61+
62+
describe('with existing addOptions', () => {
63+
beforeEach(async () => {
64+
element.addOptions = ['Substation', 'Text'];
65+
await element.updateComplete;
66+
});
67+
it('render add icon and add menu with existing addOptions', async () => {
68+
expect(element.addMenu).to.exist;
69+
expect(element.addIcon).to.exist;
70+
});
71+
72+
it('opens menu on add icon click', async () => {
73+
expect(element.addMenu.open).to.be.false;
74+
element.addIcon?.click();
75+
await element.requestUpdate();
76+
expect(element.addMenu.open).to.be.true;
77+
});
78+
});
79+
80+
describe('with missing add action', () => {
81+
let wizardEvent: SinonSpy;
82+
beforeEach(async () => {
83+
element.addOptions = ['Substation', 'Text'];
84+
await element.updateComplete;
85+
86+
wizardEvent = sinon.spy();
87+
window.addEventListener('wizard', wizardEvent);
88+
});
89+
it('does not trigger wizard action', async () => {
90+
element.addMenu.querySelector('mwc-list-item')?.click();
91+
expect(wizardEvent).to.not.have.been.called;
92+
});
93+
});
94+
95+
describe('with existing add action', () => {
96+
let wizardEvent: SinonSpy;
97+
beforeEach(async () => {
98+
element.addOptions = ['Substation', 'Text'];
99+
element.addElementAction = (tagName: string) => {
100+
return [{ title: 'wizard' }];
101+
};
102+
await element.updateComplete;
103+
104+
wizardEvent = sinon.spy();
105+
window.addEventListener('wizard', wizardEvent);
106+
});
107+
it('does trigger wizard action with valid existing wizard', async () => {
108+
element.addMenu.querySelector('mwc-list-item')?.click();
109+
expect(wizardEvent).to.have.been.called;
110+
});
111+
it('does not trigger wizard action with undefined wizard', async () => {
112+
element.addElementAction = (tagName: string) => {
113+
return undefined;
114+
};
115+
await element.updateComplete;
116+
element.addMenu.querySelector('mwc-list-item')?.click();
117+
expect(wizardEvent).to.not.have.been.called;
118+
});
119+
});
120+
});

0 commit comments

Comments
 (0)