0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Added tabs component (#15652)

closes TryGhost/Team#2086
This commit is contained in:
Elena Baidakova 2022-10-19 15:20:15 +04:00 committed by GitHub
parent b589a66cd4
commit c65b980ada
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 410 additions and 38 deletions

View file

@ -1,5 +1,4 @@
<section class="gh-canvas" {{did-insert this.loadData}}>
<GhCanvasHeader class="gh-canvas-header gh-post-analytics-header">
<div class="flex flex-column flex-grow-1">
<div class="gh-canvas-breadcrumb">
@ -42,53 +41,52 @@
</div>
</GhCanvasHeader>
<h4 class="gh-main-section-header small bn">
Engagement
</h4>
<div class="gh-post-analytics-box">
<Tabs::Tabs class="gh-tabs-analytics" as |tabs|>
{{#if this.post.hasBeenEmailed}}
<div class="gh-post-analytics-item">
<LinkTo @route="members" @query={{hash filterParam=(concat "emails.post_id:[" this.post.id "]") }}>
<h3>{{format-number this.post.email.emailCount}}</h3>
<p>Sent</p>
</LinkTo>
</div>
<tabs.tab>
<h3>Sent</h3>
<p>{{format-number this.post.email.emailCount}}</p>
</tabs.tab>
<tabs.tabPanel>Sent</tabs.tabPanel>
{{#if this.post.showEmailOpenAnalytics }}
<div class="gh-post-analytics-item">
<LinkTo @route="members" @query={{hash filterParam=(concat "opened_emails.post_id:[" this.post.id "]") }}>
<h3>{{format-number this.post.email.openedCount}}</h3>
<p>Opened &mdash; <strong>{{this.post.email.openRate}}%</strong></p>
</LinkTo>
</div>
<tabs.tab>
<h3>Opened</h3>
<p>{{format-number this.post.email.openedCount}} <strong>{{this.post.email.openRate}}%</strong></p>
</tabs.tab>
<tabs.tabPanel>Opened</tabs.tabPanel>
{{/if}}
{{#if this.post.showEmailClickAnalytics }}
<div class="gh-post-analytics-item">
<LinkTo @route="members" @query={{hash filterParam=(concat "clicked_links.post_id:[" this.post.id "]") }}>
<h3>{{format-number this.post.count.clicks}}</h3>
<p>Clicked &mdash; <strong>{{this.post.clickRate}}%</strong></p>
</LinkTo>
</div>
<tabs.tab>
<h3>Clicked</h3>
<p>{{format-number this.post.count.clicks}} <strong>{{this.post.clickRate}}%</strong></p>
</tabs.tab>
<tabs.tabPanel>Clicked</tabs.tabPanel>
{{/if}}
{{/if}}
{{#if this.post.showAttributionAnalytics }}
<div class="gh-post-analytics-item">
<LinkTo @route="members" @query={{hash filterParam=(concat "signup:[" this.post.id "]") }}>
<h3>{{format-number this.post.count.conversions}}</h3>
<p>{{gh-pluralize this.post.count.conversions "conversions" without-count=true}}</p>
</LinkTo>
</div>
{{#if this.post.showAudienceFeedback }}
<tabs.tab>
<h3><span class="hide-when-small">More </span><div class="visible-when-small">like</div><span class="hide-when-small"> this</span></h3>
<p>{{format-number this.post.count.positive_feedback}} <strong>{{this.post.count.sentiment}}%</strong></p>
</tabs.tab>
<tabs.tabPanel>More like this</tabs.tabPanel>
{{/if}}
{{#if this.post.showAudienceFeedback }}
<div class="gh-post-analytics-item">
<h3>{{format-number this.post.count.positive_feedback}}</h3>
<p><span class="hide-when-small">More </span>like<span class="hide-when-small"> this</span> &mdash; <strong>{{this.post.count.sentiment}}%</strong></p>
</div>
{{#if this.post.showAttributionAnalytics }}
<tabs.tab>
<h3>{{gh-pluralize this.post.count.conversions "Conversions" without-count=true}}</h3>
<p>{{format-number this.post.count.conversions}}</p>
</tabs.tab>
<tabs.tabPanel>Conversions</tabs.tabPanel>
{{/if}}
</div>
</Tabs::Tabs>
{{#if this.isLoaded }}
{{#if this.showLinks }}

View file

@ -0,0 +1,11 @@
<div
id="{{this.tabId}}-panel"
role="tabpanel"
tabindex="0"
aria-labelledby="{{this.tabId}}"
class="tab-panel {{if this.isSelectedTab "tab-panel-selected" }}"
>
{{#if this.isSelectedTab}}
{{yield}}
{{/if}}
</div>

View file

@ -0,0 +1,13 @@
import Component from '@glimmer/component';
export default class TabPanelComponent extends Component {
index = this.args.index();
get isSelectedTab() {
return this.args.selectedIndex === this.index;
}
get tabId() {
return this.args.tabIds[this.index];
}
}

View file

@ -0,0 +1,13 @@
<button
class="tab {{if this.isSelectedTab "tab-selected" }}"
id="{{this.id}}"
type="button"
role="tab"
aria-selected="{{this.isSelectedTab}}"
aria-controls="{{this.id}}-panel"
tabindex="{{if this.isSelectedTab "0" "-1"}}"
{{on "click" this.handleClick}}
{{on "keyup" this.handleKeyup}}
>
{{yield}}
</button>

View file

@ -0,0 +1,24 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {guidFor} from '@ember/object/internals';
export default class TabComponent extends Component {
id = this.args.id(`tab-${guidFor(this)}`);
index = this.args.index();
get isSelectedTab() {
return this.args.selectedIndex === this.index;
}
@action
handleClick() {
return this.args.onSelect(this.index);
}
@action
handleKeyup(event) {
event.stopPropagation();
event.preventDefault();
return this.args.onKeyup(event, this.index);
}
}

View file

@ -0,0 +1,30 @@
<div ...attributes>
<div
role="tablist"
class="tab-list"
>
{{yield
(hash
tab=(
component "tabs/tab"
selectedIndex=this.selectedIndex
onSelect=this.handleSelect
onKeyup=this.handleKeyup
id=this.addTabId
index=this.addTabIndex
)
)
}}
</div>
{{yield
(hash
tabPanel=(
component "tabs/tab-panel"
selectedIndex=this.selectedIndex
index=this.addPanelIndex
tabIds=this.tabIds
)
)
}}
</div>

View file

@ -0,0 +1,65 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {tracked} from '@glimmer/tracking';
export default class TabsComponent extends Component {
tabIds = [];
tabCounter = 0;
tabPanelCounter = 0;
@tracked selectedIndex = this.args.defaultIndex ?? 0;
@action
handleSelect(index) {
this.selectedIndex = index;
}
@action
handleKeyup(event, index) {
switch (event.key) {
case 'ArrowLeft':
this.selectedIndex = this.tabIds[index - 1] ? index - 1 : this.tabIds.length - 1;
break;
case 'ArrowRight':
this.selectedIndex = this.tabIds[index + 1] ? index + 1 : 0;
break;
case 'Home':
this.selectedIndex = 0;
break;
case 'End':
this.selectedIndex = this.tabIds.length - 1;
break;
default:
break;
}
const selectedNode = document.getElementById(this.tabIds[this.selectedIndex]);
selectedNode.focus();
}
@action
addTabId(id) {
this.tabIds.push(id);
return id;
}
@action
addTabIndex() {
const index = this.tabCounter;
this.tabCounter = this.tabCounter + 1;
return index;
}
@action
addPanelIndex() {
const index = this.tabPanelCounter;
this.tabPanelCounter = this.tabPanelCounter + 1;
return index;
}
}

View file

@ -467,7 +467,7 @@ a.gh-post-list-signups.active:hover > span, a.gh-post-list-conversions.active:ho
.feature-memberAttribution .gh-posts-sends-header {
width: 80px;
}
.feature-memberAttribution .gh-posts-opens-header {
width: 100px;
}
@ -635,6 +635,11 @@ a.gh-post-list-signups.active:hover > span, a.gh-post-list-conversions.active:ho
.hide-when-small {
display: none;
}
.visible-when-small::first-letter{
display: inline-block;
text-transform: uppercase;
}
}
@media (max-width: 800px) {
@ -1408,7 +1413,7 @@ a.gh-post-list-cta.stats.is-hovered:hover > * {
border-bottom: 1px solid var(--whitegrey-d1);
padding-left: 0;
padding-bottom: 2rem;
padding-top: 2rem;
padding-top: 2rem;
}
.gh-post-analytics-item:first-child {
@ -1430,3 +1435,111 @@ a.gh-post-list-cta.stats.is-hovered:hover > * {
font-size: 1.8rem;
}
}
.gh-tabs-analytics {
overflow: hidden;
margin-bottom: 22px;
border-radius: 5px;
border: 1px solid #ECEEF0;
}
.gh-tabs-analytics .tab{
display: flex;
flex-direction: column;
justify-content: flex-start;
border: 1px solid transparent;
border-bottom: none;
padding: 16px 20px;
text-align: left;
}
.gh-tabs-analytics .tab-selected{
box-sizing: border-box;
background: #ffffff;
border: 1px solid #E7E9EB;
border-bottom: none;
box-shadow: 0 4px 7px rgba(0, 0, 0, 0.05), 0 1px 0 0 #ffffff;
border-radius: 5px 5px 0 0;
}
.gh-tabs-analytics .tab-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
padding: 8px 8px 0px;
background-color: #F7F8F9;
box-shadow: inset 0 -1px 0 #eceef0;
}
.gh-tabs-analytics .tab-panel-selected {
padding: 12px 26px;
/* help to hide shadow from selected tab */
opacity: 0.99999;
background-color: #ffffff;
}
.gh-tabs-analytics h3 {
display: flex;
flex-direction: row;
gap: 3px;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.2;
}
.gh-tabs-analytics p {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px;
margin: 0;
font-size: 2.4rem;
font-weight: 600;
line-height: 1.2;
}
.gh-tabs-analytics strong {
color: #909CAB;
font-size: 1.5rem;
font-weight: 500;
line-height: 1.2;
}
@media (max-width: 1200px) {
.gh-tabs-analytics .tab{
padding: 8px 10px;
}
.gh-tabs-analytics .tab-panel-selected {
padding: 12px 18px;
}
.gh-tabs-analytics .tab-list {
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
}
.gh-tabs-analytics h3 {
font-size: 1.2rem;
}
.gh-tabs-analytics p {
font-size: 1.6rem;
}
}
@media (max-width: 440px) {
.gh-tabs-analytics .tab-list {
padding: 4px 4px 0px;
}
.gh-tabs-analytics .tab-panel-selected {
padding: 12px 14px;
}
.gh-tabs-analytics p {
font-size: 1.2rem;
}
.gh-tabs-analytics strong {
font-size: 1.2rem;
}
}

View file

@ -0,0 +1,105 @@
import {click, findAll, render, triggerKeyEvent} from '@ember/test-helpers';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {hbs} from 'ember-cli-htmlbars';
import {setupRenderingTest} from 'ember-mocha';
describe('Integration: Component: tabs/tabs', function () {
setupRenderingTest();
it('renders', async function () {
await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 1</tabs.tab>
<tabs.tab>Tab 2</tabs.tab>
<tabs.tabPanel>Content 1</tabs.tabPanel>
<tabs.tabPanel>Content 2</tabs.tabPanel>
</Tabs::Tabs>`);
const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel');
expect(findAll('.test-tab').length).to.equal(1);
expect(findAll('.tab-list').length).to.equal(1);
expect(tabPanels.length).to.equal(2);
expect(tabButtons.length).to.equal(2);
expect(findAll('.tab-selected').length).to.equal(1);
expect(findAll('.tab-panel-selected').length).to.equal(1);
expect(tabButtons[0]).to.have.class('tab-selected');
expect(tabPanels[0]).to.have.class('tab-panel-selected');
expect(tabButtons[0]).to.have.trimmed.text('Tab 1');
expect(tabButtons[1]).to.have.trimmed.text('Tab 2');
expect(tabPanels[0]).to.have.trimmed.text('Content 1');
expect(tabPanels[1]).to.have.trimmed.text('');
});
it('renders expected content on click', async function () {
await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 1</tabs.tab>
<tabs.tab>Tab 2</tabs.tab>
<tabs.tabPanel>Content 1</tabs.tabPanel>
<tabs.tabPanel>Content 2</tabs.tabPanel>
</Tabs::Tabs>`);
const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel');
await click(tabButtons[1]);
expect(findAll('.tab-selected').length).to.equal(1);
expect(findAll('.tab-panel-selected').length).to.equal(1);
expect(tabButtons[1]).to.have.class('tab-selected');
expect(tabPanels[1]).to.have.class('tab-panel-selected');
expect(tabPanels[0]).to.have.trimmed.text('');
expect(tabPanels[1]).to.have.trimmed.text('Content 2');
});
it('renders expected content on keyup event', async function () {
await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 0</tabs.tab>
<tabs.tab>Tab 1</tabs.tab>
<tabs.tab>Tab 2</tabs.tab>
<tabs.tabPanel>Content 0</tabs.tabPanel>
<tabs.tabPanel>Content 1</tabs.tabPanel>
<tabs.tabPanel>Content 2</tabs.tabPanel>
</Tabs::Tabs>`);
const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel');
const isTabRenders = (num) => {
expect(tabButtons[num]).to.have.class('tab-selected');
expect(tabPanels[num]).to.have.class('tab-panel-selected');
expect(tabPanels[num]).to.have.trimmed.text(`Content ${num}`);
};
await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowRight');
await triggerKeyEvent(tabButtons[1], 'keyup', 'ArrowRight');
isTabRenders(2);
await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowRight');
isTabRenders(0);
await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowLeft');
isTabRenders(2);
await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowLeft');
isTabRenders(1);
await triggerKeyEvent(tabButtons[0], 'keyup', 'Home');
isTabRenders(0);
await triggerKeyEvent(tabButtons[0], 'keyup', 'End');
isTabRenders(2);
});
});