Skip to content

Commit 7d618c2

Browse files
authored
feat: improvements for tabs UX (#93)
1 parent 4824813 commit 7d618c2

File tree

13 files changed

+648
-112
lines changed

13 files changed

+648
-112
lines changed

demo/md-content.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,35 @@ This *paragraph <span style="color: red;" onmouseenter="alert('XSS inline');"> h
125125
126126
<div>This is another html_block</div>
127127
128+
{% list tabs %}
129+
130+
- The name of tab1
131+
132+
The text of tab1.
133+
134+
* You can use lists.
135+
* And **other** markup.
136+
137+
- The name of tab2
138+
139+
The text of tab2.
140+
141+
- The name of tab3
142+
143+
The text of tab3.
144+
145+
- The name of tab4
146+
147+
The text of tab4.
148+
149+
- The name of tab5
150+
151+
The text of tab5.
152+
153+
- The name of tab6
154+
155+
The text of tab6.
156+
157+
{% endlist %}
158+
128159
`.trim();

src/extensions/yfm/YfmDist/yfm.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,10 @@
2121
-webkit-user-select: text;
2222
user-select: text;
2323
}
24+
25+
.yfm-tab {
26+
/* stylelint-disable-next-line property-no-vendor-prefix */
27+
-webkit-user-select: text;
28+
user-select: text;
29+
}
2430
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {builders} from 'prosemirror-test-builder';
2+
import {createMarkupChecker} from '../../../../tests/sameMarkup';
3+
import {ExtensionsManager} from '../../../core';
4+
import {BaseNode, BaseSpecsPreset} from '../../base/specs';
5+
import {blockquoteNodeName, italicMarkName} from '../../markdown/specs';
6+
import {TabsNode, YfmTabsSpecs} from './YfmTabsSpecs';
7+
8+
const generatedId = 'generated_id';
9+
10+
jest.mock('@doc-tools/transform/lib/plugins/utils', () => {
11+
return {
12+
generateID: () => generatedId,
13+
};
14+
});
15+
16+
const {schema, parser, serializer} = new ExtensionsManager({
17+
extensions: (builder) => builder.use(BaseSpecsPreset, {}).use(YfmTabsSpecs, {}),
18+
}).buildDeps();
19+
20+
const {doc, p, tab, tabs, tabPanel, tabsList} = builders(schema, {
21+
doc: {nodeType: BaseNode.Doc},
22+
p: {nodeType: BaseNode.Paragraph},
23+
i: {markType: italicMarkName},
24+
bq: {nodeType: blockquoteNodeName},
25+
tab: {nodeType: TabsNode.Tab},
26+
tabPanel: {nodeType: TabsNode.TabPanel},
27+
tabs: {nodeType: TabsNode.Tabs},
28+
tabsList: {nodeType: TabsNode.TabsList},
29+
}) as PMTestBuilderResult<'doc' | 'p' | 'bq' | 'tab' | 'tabPanel' | 'tabs' | 'tabsList'>;
30+
31+
const {same} = createMarkupChecker({parser, serializer});
32+
33+
describe('YfmTabs extension', () => {
34+
it('should parse yfm-tabs', () => {
35+
const markup = `
36+
{% list tabs %}
37+
38+
- panel title 1
39+
40+
panel content 1
41+
42+
- panel title 2
43+
44+
panel content 2
45+
46+
{% endlist %}
47+
`.trim();
48+
49+
same(
50+
markup,
51+
doc(
52+
tabs(
53+
{
54+
class: 'yfm-tabs',
55+
},
56+
tabsList(
57+
{
58+
class: 'yfm-tab-list',
59+
role: 'tablist',
60+
},
61+
tab(
62+
{
63+
id: generatedId,
64+
class: 'yfm-tab active',
65+
role: 'tab',
66+
'aria-controls': generatedId,
67+
'aria-selected': 'true',
68+
tabindex: '0',
69+
},
70+
'panel title 1',
71+
),
72+
tab(
73+
{
74+
id: generatedId,
75+
class: 'yfm-tab',
76+
role: 'tab',
77+
'aria-controls': generatedId,
78+
'aria-selected': 'false',
79+
tabindex: '-1',
80+
},
81+
'panel title 2',
82+
),
83+
),
84+
tabPanel(
85+
{
86+
id: generatedId,
87+
class: 'yfm-tab-panel active',
88+
role: 'tabpanel',
89+
'data-title': 'panel title 1',
90+
'aria-labelledby': generatedId,
91+
},
92+
p('panel content 1'),
93+
),
94+
tabPanel(
95+
{
96+
id: generatedId,
97+
class: 'yfm-tab-panel',
98+
role: 'tabpanel',
99+
'data-title': 'panel title 2',
100+
'aria-labelledby': generatedId,
101+
},
102+
p('panel content 2'),
103+
),
104+
),
105+
),
106+
);
107+
});
108+
});

src/extensions/yfm/YfmTabs/YfmTabsSpecs/const.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,29 @@ export enum TabsNode {
44
TabPanel = 'yfm_tab_panel',
55
Tabs = 'yfm_tabs',
66
}
7+
8+
export enum TabsAttrs {
9+
class = 'class',
10+
}
11+
12+
export enum TabsListAttrs {
13+
class = 'class',
14+
role = 'role',
15+
}
16+
17+
export enum TabAttrs {
18+
id = 'id',
19+
class = 'class',
20+
role = 'role',
21+
ariaControls = 'aria-controls',
22+
ariaSelected = 'aria-selected',
23+
tabindex = 'tabindex',
24+
}
25+
26+
export enum TabPanelAttrs {
27+
id = 'id',
28+
class = 'class',
29+
role = 'role',
30+
dataTitle = 'data-title',
31+
ariaLabelledby = 'aria-labelledby',
32+
}

src/extensions/yfm/YfmTabs/YfmTabsSpecs/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import log from '@doc-tools/transform/lib/log';
22
import yfmPlugin from '@doc-tools/transform/lib/plugins/tabs';
3+
import {NodeSpec} from 'prosemirror-model';
34

45
import type {ExtensionAuto, YENodeSpec} from '../../../../core';
56
import {nodeTypeFactory} from '../../../../utils/schema';
67
import {TabsNode} from './const';
78
import {fromYfm} from './fromYfm';
8-
import {spec} from './spec';
9+
import {getSpec} from './spec';
910
import {toYfm} from './toYfm';
1011

1112
export {TabsNode} from './const';
@@ -15,13 +16,16 @@ export const tabsType = nodeTypeFactory(TabsNode.Tabs);
1516
export const tabsListType = nodeTypeFactory(TabsNode.TabsList);
1617

1718
export type YfmTabsSpecsOptions = {
19+
tabPlaceholder?: NonNullable<NodeSpec['placeholder']>['content'];
1820
tabView?: YENodeSpec['view'];
1921
tabsListView?: YENodeSpec['view'];
2022
tabPanelView?: YENodeSpec['view'];
2123
tabsView?: YENodeSpec['view'];
2224
};
2325

2426
export const YfmTabsSpecs: ExtensionAuto<YfmTabsSpecsOptions> = (builder, opts) => {
27+
const spec = getSpec(opts);
28+
2529
builder
2630
.configureMd((md) => md.use(yfmPlugin, {log}))
2731
.addNode(TabsNode.Tab, () => ({
Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import type {NodeSpec} from 'prosemirror-model';
2-
import {TabsNode} from './const';
2+
import {YfmTabsSpecsOptions} from '.';
3+
import {TabAttrs, TabPanelAttrs, TabsAttrs, TabsListAttrs, TabsNode} from './const';
34

4-
export const spec: Record<TabsNode, NodeSpec> = {
5+
const DEFAULT_PLACEHOLDERS = {
6+
TabTitle: 'Tab title',
7+
};
8+
9+
export const getSpec: (opts: YfmTabsSpecsOptions) => Record<TabsNode, NodeSpec> = (opts) => ({
510
[TabsNode.Tab]: {
611
attrs: {
7-
id: {default: 'unknown'},
8-
class: {default: 'unknown'},
9-
role: {default: 'unknown'},
10-
'aria-controls': {default: 'unknown'},
11-
'aria-selected': {default: 'unknown'},
12-
tabindex: {default: 'unknown'},
12+
[TabAttrs.id]: {default: 'unknown'},
13+
[TabAttrs.class]: {default: 'unknown'},
14+
[TabAttrs.role]: {default: 'unknown'},
15+
[TabAttrs.ariaControls]: {default: 'unknown'},
16+
[TabAttrs.ariaSelected]: {default: 'unknown'},
17+
[TabAttrs.tabindex]: {default: 'unknown'},
1318
},
1419
marks: '',
1520
content: 'text*',
@@ -18,18 +23,22 @@ export const spec: Record<TabsNode, NodeSpec> = {
1823
toDOM(node) {
1924
return ['div', node.attrs, 0];
2025
},
26+
placeholder: {
27+
content: opts?.tabPlaceholder ?? DEFAULT_PLACEHOLDERS.TabTitle,
28+
alwaysVisible: true,
29+
},
2130
selectable: false,
2231
allowSelection: false,
2332
complex: 'leaf',
2433
},
2534

2635
[TabsNode.TabPanel]: {
2736
attrs: {
28-
id: {default: 'unknown'},
29-
class: {default: 'unknown'},
30-
role: {default: 'unknown'},
31-
'data-title': {default: 'unknown'},
32-
'aria-labelledby': {default: 'unknown'},
37+
[TabPanelAttrs.id]: {default: 'unknown'},
38+
[TabPanelAttrs.class]: {default: 'unknown'},
39+
[TabPanelAttrs.role]: {default: 'unknown'},
40+
[TabPanelAttrs.dataTitle]: {default: 'unknown'},
41+
[TabPanelAttrs.ariaLabelledby]: {default: 'unknown'},
3342
},
3443
content: 'block*',
3544
group: 'block',
@@ -40,10 +49,11 @@ export const spec: Record<TabsNode, NodeSpec> = {
4049
selectable: false,
4150
allowSelection: false,
4251
complex: 'leaf',
52+
isolating: true,
4353
},
4454

4555
[TabsNode.Tabs]: {
46-
attrs: {class: {default: 'unknown'}},
56+
attrs: {[TabsAttrs.class]: {default: 'unknown'}},
4757
content: 'yfm_tabs_list yfm_tab_panel+',
4858
group: 'block',
4959
parseDOM: [{tag: 'div.yfm-tabs'}],
@@ -54,7 +64,10 @@ export const spec: Record<TabsNode, NodeSpec> = {
5464
},
5565

5666
[TabsNode.TabsList]: {
57-
attrs: {class: {default: 'unknown'}, role: {default: 'unknown'}},
67+
attrs: {
68+
[TabsListAttrs.class]: {default: 'unknown'},
69+
[TabsListAttrs.role]: {default: 'unknown'},
70+
},
5871
content: 'yfm_tab*',
5972
group: 'block',
6073
parseDOM: [{tag: 'div.yfm-tab-list'}],
@@ -65,4 +78,4 @@ export const spec: Record<TabsNode, NodeSpec> = {
6578
allowSelection: false,
6679
complex: 'inner',
6780
},
68-
};
81+
});

src/extensions/yfm/YfmTabs/YfmTabsSpecs/toYfm.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {Node} from 'prosemirror-model';
2+
import {getPlaceholderContent} from '../../../../utils/placeholder';
23
import type {SerializerNodeToken} from '../../../../core';
34
import {TabsNode} from './const';
45

@@ -21,11 +22,12 @@ export const toYfm: Record<TabsNode, SerializerNodeToken> = {
2122
const tabList = children[0].content;
2223

2324
tabList.forEach((tab, _, i) => {
24-
state.write('- ' + tab.textContent + '\n\n');
25+
state.write('- ' + (tab.textContent || getPlaceholderContent(tab)) + '\n\n');
2526
if (children[i + 1]) state.renderList(children[i + 1], ' ', () => ' ');
2627
});
2728

28-
state.write('{% endlist %}\n\n');
29+
state.write('{% endlist %}');
30+
state.closeBlock(node);
2931
},
3032
[TabsNode.TabsList]: (state, node) => {
3133
state.renderList(node, ' ', () => (node.attrs.bullet || '-') + ' ');
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const crossSvg = `<svg width="10" height="10" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg">
2+
<path fill="currentColor" d="M9.75592 8.57741C10.0814 8.90285 10.0814 9.43049 9.75592 9.75592C9.43049 10.0814 8.90285 10.0814 8.57741 9.75592L5 6.17851L1.42259 9.75592C1.09715 10.0814 0.569515 10.0814 0.244078 9.75592C-0.0813592 9.43049 -0.0813592 8.90285 0.244078 8.57741L3.82149 5L0.244078 1.42259C-0.0813592 1.09715 -0.0813592 0.569515 0.244078 0.244078C0.569515 -0.0813592 1.09715 -0.0813592 1.42259 0.244078L5 3.82149L8.57741 0.244078C8.90285 -0.0813592 9.43049 -0.0813592 9.75592 0.244078C10.0814 0.569515 10.0814 1.09715 9.75592 1.42259L6.17851 5L9.75592 8.57741Z"/>
3+
</svg>`;
4+
5+
export const plusSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
6+
<path fill="currentColor" d="M9 7h4a1 1 0 1 1 0 2H9v4a1 1 0 1 1-2 0V9H3a1 1 0 1 1 0-2h4V3a1 1 0 1 1 2 0v4z"/>
7+
</svg>`;

0 commit comments

Comments
 (0)