@dhis2-ui/tab 10.16.2 → 10.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -5
- package/src/index.js +2 -0
- package/src/tab/features/accepts_children/index.js +12 -0
- package/src/tab/features/accepts_children.feature +5 -0
- package/src/tab/features/accepts_icon/index.js +12 -0
- package/src/tab/features/accepts_icon.feature +5 -0
- package/src/tab/features/is_clickable/index.js +25 -0
- package/src/tab/features/is_clickable.feature +11 -0
- package/src/tab/index.js +1 -0
- package/src/tab/tab.e2e.stories.js +14 -0
- package/src/tab/tab.js +184 -0
- package/src/tab-bar/animated-side-scroll.js +63 -0
- package/src/tab-bar/detect-horizontal-scrollbar-height.js +36 -0
- package/src/tab-bar/features/accepts_children/index.js +17 -0
- package/src/tab-bar/features/accepts_children.feature +9 -0
- package/src/tab-bar/features/auto_hides_scroll_buttons/index.js +35 -0
- package/src/tab-bar/features/auto_hides_scroll_buttons.feature +29 -0
- package/src/tab-bar/index.js +1 -0
- package/src/tab-bar/scroll-bar.js +219 -0
- package/src/tab-bar/scroll-button.js +72 -0
- package/src/tab-bar/tab-bar.e2e.stories.js +31 -0
- package/src/tab-bar/tab-bar.js +44 -0
- package/src/tab-bar/tab-bar.prod.stories.js +191 -0
- package/src/tab-bar/tabs.js +91 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dhis2-ui/tab",
|
|
3
|
-
"version": "10.16.
|
|
3
|
+
"version": "10.16.3",
|
|
4
4
|
"description": "UI Tabs",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,16 +32,17 @@
|
|
|
32
32
|
"styled-jsx": "^4"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@dhis2-ui/tooltip": "10.16.
|
|
35
|
+
"@dhis2-ui/tooltip": "10.16.3",
|
|
36
36
|
"@dhis2/prop-types": "^3.1.2",
|
|
37
|
-
"@dhis2/ui-constants": "10.16.
|
|
38
|
-
"@dhis2/ui-icons": "10.16.
|
|
37
|
+
"@dhis2/ui-constants": "10.16.3",
|
|
38
|
+
"@dhis2/ui-icons": "10.16.3",
|
|
39
39
|
"classnames": "^2.3.1",
|
|
40
40
|
"prop-types": "^15.7.2"
|
|
41
41
|
},
|
|
42
42
|
"files": [
|
|
43
43
|
"build",
|
|
44
|
-
"types"
|
|
44
|
+
"types",
|
|
45
|
+
"src"
|
|
45
46
|
],
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"react": "^18.3.1",
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Given, Then } from '@badeball/cypress-cucumber-preprocessor'
|
|
2
|
+
|
|
3
|
+
Given('a Tab with children is rendered', () => {
|
|
4
|
+
cy.visitStory('Tab', 'With children')
|
|
5
|
+
cy.get('[data-test="dhis2-uicore-tab"]').should('be.visible')
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
Then('the children are visible', () => {
|
|
9
|
+
cy.get('[data-test="dhis2-uicore-tab"]')
|
|
10
|
+
.contains('I am a child')
|
|
11
|
+
.should('be.visible')
|
|
12
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Given, Then } from '@badeball/cypress-cucumber-preprocessor'
|
|
2
|
+
|
|
3
|
+
Given('a Tab with an icon is rendered', () => {
|
|
4
|
+
cy.visitStory('Tab', 'With icon')
|
|
5
|
+
cy.get('[data-test="dhis2-uicore-tab"]').should('be.visible')
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
Then('the icon is visible', () => {
|
|
9
|
+
cy.get('[data-test="dhis2-uicore-tab"]')
|
|
10
|
+
.contains('Icon')
|
|
11
|
+
.should('be.visible')
|
|
12
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Given, When, Then } from '@badeball/cypress-cucumber-preprocessor'
|
|
2
|
+
|
|
3
|
+
Given('a Tab with onClick handler is rendered', () => {
|
|
4
|
+
cy.visitStory('Tab', 'With on click')
|
|
5
|
+
})
|
|
6
|
+
|
|
7
|
+
Given('a disabled Tab with onClick handler is rendered', () => {
|
|
8
|
+
cy.visitStory('Tab', 'With on click and disabled')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
When('the Tab is clicked', () => {
|
|
12
|
+
cy.get('[data-test="dhis2-uicore-tab"]').click()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
Then('the onClick handler is called', () => {
|
|
16
|
+
cy.window().should((win) => {
|
|
17
|
+
expect(win.onClick).to.be.calledWith({})
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
Then('the onClick handler is not called', () => {
|
|
22
|
+
cy.window().should((win) => {
|
|
23
|
+
expect(win.onClick).not.to.be.called
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Feature: The Tab has an onClick api
|
|
2
|
+
|
|
3
|
+
Scenario: The user clicks on the Tab
|
|
4
|
+
Given a Tab with onClick handler is rendered
|
|
5
|
+
When the Tab is clicked
|
|
6
|
+
Then the onClick handler is called
|
|
7
|
+
|
|
8
|
+
Scenario: The user clicks on a disabled Tab
|
|
9
|
+
Given a disabled Tab with onClick handler is rendered
|
|
10
|
+
When the Tab is clicked
|
|
11
|
+
Then the onClick handler is not called
|
package/src/tab/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Tab } from './tab.js'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Tab } from './tab.js'
|
|
3
|
+
|
|
4
|
+
window.onClick = window.Cypress && window.Cypress.cy.stub()
|
|
5
|
+
|
|
6
|
+
export default { title: 'Tab' }
|
|
7
|
+
export const WithOnClick = () => <Tab onClick={window.onClick}>Tab A</Tab>
|
|
8
|
+
export const WithChildren = () => <Tab>I am a child</Tab>
|
|
9
|
+
export const WithIcon = () => <Tab icon={<div>Icon</div>}>Children</Tab>
|
|
10
|
+
export const WithOnClickAndDisabled = () => (
|
|
11
|
+
<Tab onClick={window.onClick} disabled>
|
|
12
|
+
Tab A
|
|
13
|
+
</Tab>
|
|
14
|
+
)
|
package/src/tab/tab.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { colors, theme } from '@dhis2/ui-constants'
|
|
2
|
+
import { Tooltip } from '@dhis2-ui/tooltip'
|
|
3
|
+
import cx from 'classnames'
|
|
4
|
+
import PropTypes from 'prop-types'
|
|
5
|
+
import React, { useState, useEffect, useRef } from 'react'
|
|
6
|
+
|
|
7
|
+
export const Tab = React.forwardRef(
|
|
8
|
+
(
|
|
9
|
+
{
|
|
10
|
+
icon,
|
|
11
|
+
onClick,
|
|
12
|
+
selected,
|
|
13
|
+
disabled,
|
|
14
|
+
children,
|
|
15
|
+
className,
|
|
16
|
+
dataTest = 'dhis2-uicore-tab',
|
|
17
|
+
},
|
|
18
|
+
ref
|
|
19
|
+
) => {
|
|
20
|
+
let tabRef = useRef(null)
|
|
21
|
+
if (ref) {
|
|
22
|
+
tabRef = ref
|
|
23
|
+
}
|
|
24
|
+
const [isOverflowing, setIsOverflowing] = useState(false)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const checkOverflow = () => {
|
|
28
|
+
const isOverflow =
|
|
29
|
+
tabRef.current.scrollWidth > tabRef.current.clientWidth
|
|
30
|
+
setIsOverflowing(isOverflow)
|
|
31
|
+
}
|
|
32
|
+
checkOverflow()
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
className={`${cx('tab', className, {
|
|
38
|
+
selected,
|
|
39
|
+
disabled,
|
|
40
|
+
})}`}
|
|
41
|
+
onClick={disabled ? undefined : (event) => onClick({}, event)}
|
|
42
|
+
data-test={dataTest}
|
|
43
|
+
ref={tabRef}
|
|
44
|
+
role="tab"
|
|
45
|
+
aria-selected={selected ? 'true' : 'false'}
|
|
46
|
+
aria-disabled={disabled ? 'true' : 'false'}
|
|
47
|
+
onFocus={disabled ? undefined : (event) => onClick({}, event)}
|
|
48
|
+
tabIndex={-1}
|
|
49
|
+
>
|
|
50
|
+
{icon}
|
|
51
|
+
{isOverflowing ? (
|
|
52
|
+
<Tooltip content={children} maxWidth={'100%'}>
|
|
53
|
+
<span ref={tabRef}>{children}</span>
|
|
54
|
+
</Tooltip>
|
|
55
|
+
) : (
|
|
56
|
+
<span ref={tabRef}>{children}</span>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
<style jsx>{`
|
|
60
|
+
button {
|
|
61
|
+
flex-grow: 0;
|
|
62
|
+
position: relative;
|
|
63
|
+
display: inline-flex;
|
|
64
|
+
justify-content: center;
|
|
65
|
+
align-items: center;
|
|
66
|
+
vertical-align: bottom;
|
|
67
|
+
|
|
68
|
+
height: 100%;
|
|
69
|
+
padding: 16px 16px 11px;
|
|
70
|
+
|
|
71
|
+
background-color: transparent;
|
|
72
|
+
outline: none;
|
|
73
|
+
border: none;
|
|
74
|
+
border-bottom: 1px solid ${colors.grey400};
|
|
75
|
+
|
|
76
|
+
color: ${colors.grey600};
|
|
77
|
+
font-size: 14px;
|
|
78
|
+
line-height: 20px;
|
|
79
|
+
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
:global(.fixed) > button {
|
|
84
|
+
flex-grow: 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
button::after {
|
|
88
|
+
content: ' ';
|
|
89
|
+
display: block;
|
|
90
|
+
position: absolute;
|
|
91
|
+
bottom: -1px;
|
|
92
|
+
inset-inline-start: 0;
|
|
93
|
+
height: 4px;
|
|
94
|
+
width: 100%;
|
|
95
|
+
background-color: transparent;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
span {
|
|
99
|
+
display: inline-flex;
|
|
100
|
+
max-width: 320px;
|
|
101
|
+
white-space: nowrap;
|
|
102
|
+
overflow: hidden;
|
|
103
|
+
text-overflow: ellipsis;
|
|
104
|
+
transition: fill 150ms ease-in-out;
|
|
105
|
+
}
|
|
106
|
+
/*focus-visible backwards compatibility for safari: https://css-tricks.com/platform-news-using-focus-visible-bbcs-new-typeface-declarative-shadow-doms-a11y-and-placeholders/*/
|
|
107
|
+
button:focus {
|
|
108
|
+
outline: 3px solid ${theme.focus};
|
|
109
|
+
outline-offset: -3px;
|
|
110
|
+
}
|
|
111
|
+
button:focus:not(:focus-visible) {
|
|
112
|
+
outline: none;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
button > :global(svg) {
|
|
116
|
+
fill: ${colors.grey600};
|
|
117
|
+
width: 14px;
|
|
118
|
+
height: 14px;
|
|
119
|
+
margin: 0 4px 0 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
button:hover {
|
|
123
|
+
color: ${colors.grey900};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
button:hover::after {
|
|
127
|
+
background-color: ${colors.grey600};
|
|
128
|
+
height: 2px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
button:active::after {
|
|
132
|
+
background-color: ${colors.grey800};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
button.selected {
|
|
136
|
+
color: ${theme.primary800};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
button.selected::after {
|
|
140
|
+
background-color: ${theme.primary700};
|
|
141
|
+
transition: background-color 150ms ease-in-out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
button.selected:hover::after {
|
|
145
|
+
background-color: ${theme.primary700};
|
|
146
|
+
height: 4px;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
button.selected > :global(svg) {
|
|
150
|
+
fill: ${theme.primary700};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
button.disabled {
|
|
154
|
+
color: ${colors.grey500};
|
|
155
|
+
cursor: not-allowed;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
button.disabled:hover,
|
|
159
|
+
button.selected:hover {
|
|
160
|
+
background-color: transparent;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
button.disabled > :global(svg) {
|
|
164
|
+
fill: ${colors.grey500};
|
|
165
|
+
}
|
|
166
|
+
`}</style>
|
|
167
|
+
</button>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
Tab.propTypes = {
|
|
173
|
+
children: PropTypes.node,
|
|
174
|
+
className: PropTypes.string,
|
|
175
|
+
dataTest: PropTypes.string,
|
|
176
|
+
disabled: PropTypes.bool,
|
|
177
|
+
icon: PropTypes.element,
|
|
178
|
+
/** Indicates this tab is selected */
|
|
179
|
+
selected: PropTypes.bool,
|
|
180
|
+
/** Called with the signature `({}, event)` */
|
|
181
|
+
onClick: PropTypes.func,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
Tab.displayName = 'Tab'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const DURATION = 250
|
|
2
|
+
const SCROLL_STEP = 0.5
|
|
3
|
+
|
|
4
|
+
export function animatedSideScroll(scrollBox, callback, goBackwards = false) {
|
|
5
|
+
const startValue = scrollBox.scrollLeft
|
|
6
|
+
const endValue = getEndValue(scrollBox, startValue, goBackwards)
|
|
7
|
+
const change = endValue - startValue
|
|
8
|
+
const step = createFrameStepper({
|
|
9
|
+
scrollBox,
|
|
10
|
+
callback,
|
|
11
|
+
startValue,
|
|
12
|
+
endValue,
|
|
13
|
+
change,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
window.requestAnimationFrame(step)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getEndValue(scrollBox, startValue, goBackwards) {
|
|
20
|
+
const scrollDistance = scrollBox.clientWidth * SCROLL_STEP
|
|
21
|
+
const inverter = goBackwards ? -1 : 1
|
|
22
|
+
return Math.floor(startValue + scrollDistance * inverter)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createFrameStepper({
|
|
26
|
+
scrollBox,
|
|
27
|
+
callback,
|
|
28
|
+
startValue,
|
|
29
|
+
endValue,
|
|
30
|
+
change,
|
|
31
|
+
}) {
|
|
32
|
+
let startTimestamp, elapsedTime, scrollValue
|
|
33
|
+
|
|
34
|
+
return function step(timestamp) {
|
|
35
|
+
if (!startTimestamp) {
|
|
36
|
+
startTimestamp = timestamp
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
elapsedTime = timestamp - startTimestamp
|
|
40
|
+
scrollValue = easeInOutQuad({
|
|
41
|
+
currentTime: elapsedTime,
|
|
42
|
+
DURATION,
|
|
43
|
+
startValue,
|
|
44
|
+
change,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if (elapsedTime >= DURATION) {
|
|
48
|
+
if (scrollValue !== endValue) {
|
|
49
|
+
scrollBox.scrollLeft = endValue
|
|
50
|
+
}
|
|
51
|
+
callback && callback()
|
|
52
|
+
} else {
|
|
53
|
+
scrollBox.scrollLeft = scrollValue
|
|
54
|
+
window.requestAnimationFrame(step)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function easeInOutQuad({ currentTime, startValue, change }) {
|
|
60
|
+
return (currentTime /= DURATION / 2) < 1
|
|
61
|
+
? (change / 2) * currentTime * currentTime + startValue
|
|
62
|
+
: (-change / 2) * (--currentTime * (currentTime - 2) - 1) + startValue
|
|
63
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
let horizontalScrollbarHeight
|
|
2
|
+
const className = '__vertical-scrollbar-height-test__'
|
|
3
|
+
const styles = `
|
|
4
|
+
.${className} {
|
|
5
|
+
position: absolute;
|
|
6
|
+
top: -9999px;
|
|
7
|
+
width: 100px;
|
|
8
|
+
height: 100px;
|
|
9
|
+
overflow-x: scroll;
|
|
10
|
+
}
|
|
11
|
+
.${className}::-webkit-scrollbar {
|
|
12
|
+
display: none;
|
|
13
|
+
}
|
|
14
|
+
`
|
|
15
|
+
|
|
16
|
+
export function detectHorizontalScrollbarHeight() {
|
|
17
|
+
if (horizontalScrollbarHeight) {
|
|
18
|
+
return horizontalScrollbarHeight
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const style = document.createElement('style')
|
|
22
|
+
style.innerHTML = styles
|
|
23
|
+
|
|
24
|
+
const el = document.createElement('div')
|
|
25
|
+
el.classList.add(className)
|
|
26
|
+
|
|
27
|
+
document.body.appendChild(style)
|
|
28
|
+
document.body.appendChild(el)
|
|
29
|
+
|
|
30
|
+
horizontalScrollbarHeight = el.offsetHeight - el.clientHeight
|
|
31
|
+
|
|
32
|
+
document.body.removeChild(style)
|
|
33
|
+
document.body.removeChild(el)
|
|
34
|
+
|
|
35
|
+
return horizontalScrollbarHeight
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Given, Then } from '@badeball/cypress-cucumber-preprocessor'
|
|
2
|
+
|
|
3
|
+
Given('a TabBar with children is rendered', () => {
|
|
4
|
+
cy.visitStory('TabBar', 'With children')
|
|
5
|
+
cy.get('[data-test="dhis2-uicore-tabbar"]').should('be.visible')
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
Given('a scrollable TabBar with children is rendered', () => {
|
|
9
|
+
cy.visitStory('TabBar', 'Scrollable with children')
|
|
10
|
+
cy.get('[data-test="dhis2-uicore-tabbar"]').should('be.visible')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
Then('the children are visible', () => {
|
|
14
|
+
cy.get('[data-test="dhis2-uicore-tabbar"]')
|
|
15
|
+
.contains('I am a child')
|
|
16
|
+
.should('be.visible')
|
|
17
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Feature: The TabBar renders children
|
|
2
|
+
|
|
3
|
+
Scenario: A TabBar with children
|
|
4
|
+
Given a TabBar with children is rendered
|
|
5
|
+
Then the children are visible
|
|
6
|
+
|
|
7
|
+
Scenario: A scrollable TabBar with children
|
|
8
|
+
Given a scrollable TabBar with children is rendered
|
|
9
|
+
Then the children are visible
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Given, When, Then } from '@badeball/cypress-cucumber-preprocessor'
|
|
2
|
+
|
|
3
|
+
Given('a tabbar with enough space for all tabs is rendered', () => {
|
|
4
|
+
cy.viewport(1024, 768)
|
|
5
|
+
cy.visitStory('TabBar', 'Scrollable with some tabs')
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
Given('the tabbar is scrollable', () => {
|
|
9
|
+
cy.get('.scroll-area').should('exist')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
Given('a tabbar with too little space for all tabs is rendered', () => {
|
|
13
|
+
cy.viewport(300, 768)
|
|
14
|
+
cy.visitStory('TabBar', 'Scrollable with some tabs')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
When('the tabs are visible', () => {
|
|
18
|
+
cy.get('[data-test="dhis2-uicore-tab"]').should('exist')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
When("the tabbar's width increases", () => {
|
|
22
|
+
cy.viewport(1024, 768)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
When("the tabbar's width decreases", () => {
|
|
26
|
+
cy.viewport(300, 768)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
Then('no scroll buttons should be visible', () => {
|
|
30
|
+
cy.get('.scroll-button:visible').should('not.exist')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
Then('both scroll buttons should be visible', () => {
|
|
34
|
+
cy.get('.scroll-button:visible').should('have.length.of', 2)
|
|
35
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Feature: The TabBar hides scroll buttons automatically when there is enough space for all tabs
|
|
2
|
+
|
|
3
|
+
Scenario: There is enough space for the tabs initially
|
|
4
|
+
Given a tabbar with enough space for all tabs is rendered
|
|
5
|
+
And the tabbar is scrollable
|
|
6
|
+
When the tabs are visible
|
|
7
|
+
Then no scroll buttons should be visible
|
|
8
|
+
|
|
9
|
+
Scenario: There is not enough space for the tabs initially
|
|
10
|
+
Given a tabbar with too little space for all tabs is rendered
|
|
11
|
+
And the tabbar is scrollable
|
|
12
|
+
When the tabs are visible
|
|
13
|
+
Then both scroll buttons should be visible
|
|
14
|
+
|
|
15
|
+
Scenario: The buttons hide when the the tabbar's width increases
|
|
16
|
+
Given a tabbar with too little space for all tabs is rendered
|
|
17
|
+
And the tabbar is scrollable
|
|
18
|
+
When the tabs are visible
|
|
19
|
+
Then both scroll buttons should be visible
|
|
20
|
+
When the tabbar's width increases
|
|
21
|
+
Then no scroll buttons should be visible
|
|
22
|
+
|
|
23
|
+
Scenario: There is enough space for the tabs initially
|
|
24
|
+
Given a tabbar with enough space for all tabs is rendered
|
|
25
|
+
And the tabbar is scrollable
|
|
26
|
+
When the tabs are visible
|
|
27
|
+
Then no scroll buttons should be visible
|
|
28
|
+
When the tabbar's width decreases
|
|
29
|
+
Then both scroll buttons should be visible
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { TabBar } from './tab-bar.js'
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { IconChevronRight16, IconChevronLeft16 } from '@dhis2/ui-icons'
|
|
2
|
+
import cx from 'classnames'
|
|
3
|
+
import PropTypes from 'prop-types'
|
|
4
|
+
import React, { Component, createRef } from 'react'
|
|
5
|
+
import { animatedSideScroll } from './animated-side-scroll.js'
|
|
6
|
+
import { detectHorizontalScrollbarHeight } from './detect-horizontal-scrollbar-height.js'
|
|
7
|
+
import { ScrollButton } from './scroll-button.js'
|
|
8
|
+
|
|
9
|
+
class ScrollBar extends Component {
|
|
10
|
+
scrollBox = createRef()
|
|
11
|
+
scrollArea = createRef()
|
|
12
|
+
horizontalScrollBarHeight = detectHorizontalScrollbarHeight()
|
|
13
|
+
scrollBoxResizeObserver = null
|
|
14
|
+
|
|
15
|
+
constructor(props) {
|
|
16
|
+
super(props)
|
|
17
|
+
this.state = {
|
|
18
|
+
scrolledToStart: true,
|
|
19
|
+
scrolledToEnd: false,
|
|
20
|
+
// used to initially hide the entire content to prevent flickering
|
|
21
|
+
hideScrollButtonsInitialized: false,
|
|
22
|
+
// hide buttons initially to simplify calculations
|
|
23
|
+
hideScrollButtons: false,
|
|
24
|
+
}
|
|
25
|
+
this.scrollBoxResizeObserver = new ResizeObserver(
|
|
26
|
+
this.calculateShouldHideButtons
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
componentDidMount() {
|
|
31
|
+
this.scrollSelectedTabIntoView()
|
|
32
|
+
this.attachSideScrollListener()
|
|
33
|
+
this.manageShouldHideButtons()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
componentWillUnmount() {
|
|
37
|
+
this.removeSideScrollListener()
|
|
38
|
+
this.scrollBoxResizeObserver.disconnect()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
manageShouldHideButtons() {
|
|
42
|
+
const { current: scrollBox } = this.scrollBox
|
|
43
|
+
this.scrollBoxResizeObserver.observe(scrollBox)
|
|
44
|
+
this.calculateShouldHideButtons()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
calculateShouldHideButtons = () => {
|
|
48
|
+
this.setState({ hideScrollButtonsInitialized: false })
|
|
49
|
+
|
|
50
|
+
const { current: scrollBox } = this.scrollBox
|
|
51
|
+
const { current: scrollArea } = this.scrollArea
|
|
52
|
+
|
|
53
|
+
const areaWidth = scrollArea.offsetWidth
|
|
54
|
+
const boxWidth = scrollBox.offsetWidth
|
|
55
|
+
const hideScrollButtons = areaWidth <= boxWidth
|
|
56
|
+
|
|
57
|
+
this.setState({ hideScrollButtons })
|
|
58
|
+
|
|
59
|
+
if (!hideScrollButtons) {
|
|
60
|
+
if (this.state.scrolledToStart) {
|
|
61
|
+
this.scrollLeft()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (this.state.scrolledToEnd) {
|
|
65
|
+
this.scrollRight()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.setState({ hideScrollButtonsInitialized: true })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
scrollRight = () => this.scroll()
|
|
73
|
+
|
|
74
|
+
scrollLeft = () => this.scroll(true)
|
|
75
|
+
|
|
76
|
+
scroll(goBackwards) {
|
|
77
|
+
this.removeSideScrollListener()
|
|
78
|
+
|
|
79
|
+
animatedSideScroll(
|
|
80
|
+
this.scrollBox.current,
|
|
81
|
+
this.animatedScrollCallback,
|
|
82
|
+
goBackwards
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
animatedScrollCallback = () => {
|
|
87
|
+
this.updateScrolledToStates()
|
|
88
|
+
this.attachSideScrollListener()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
updateScrolledToStates = () => {
|
|
92
|
+
const { scrollLeft, offsetWidth } = this.scrollBox.current
|
|
93
|
+
const { offsetWidth: areaOffsetWidth } = this.scrollArea.current
|
|
94
|
+
const scrolledToStart = scrollLeft <= 0
|
|
95
|
+
const scrolledToEnd = scrollLeft + offsetWidth >= areaOffsetWidth
|
|
96
|
+
|
|
97
|
+
if (
|
|
98
|
+
this.state.scrolledToStart !== scrolledToStart ||
|
|
99
|
+
this.state.scrolledToEnd !== scrolledToEnd
|
|
100
|
+
) {
|
|
101
|
+
this.setState({
|
|
102
|
+
scrolledToStart,
|
|
103
|
+
scrolledToEnd,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
scrollSelectedTabIntoView() {
|
|
109
|
+
const scrollBoxEl = this.scrollBox.current
|
|
110
|
+
const tab = scrollBoxEl.querySelector('.tab.selected')
|
|
111
|
+
|
|
112
|
+
if (tab) {
|
|
113
|
+
const tabEnd = tab.offsetLeft + tab.offsetWidth
|
|
114
|
+
|
|
115
|
+
if (tabEnd > scrollBoxEl.offsetWidth) {
|
|
116
|
+
scrollBoxEl.scrollLeft = tabEnd - scrollBoxEl.offsetWidth
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
attachSideScrollListener() {
|
|
122
|
+
this.scrollBox.current.addEventListener(
|
|
123
|
+
'scroll',
|
|
124
|
+
this.updateScrolledToStates
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
removeSideScrollListener() {
|
|
129
|
+
this.scrollBox.current.removeEventListener(
|
|
130
|
+
'scroll',
|
|
131
|
+
this.updateScrolledToStates
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
render() {
|
|
136
|
+
const {
|
|
137
|
+
scrolledToStart,
|
|
138
|
+
scrolledToEnd,
|
|
139
|
+
hideScrollButtonsInitialized,
|
|
140
|
+
hideScrollButtons: hideScrollButtonsState,
|
|
141
|
+
} = this.state
|
|
142
|
+
const { children, className, dataTest } = this.props
|
|
143
|
+
const hideScrollButtons =
|
|
144
|
+
!hideScrollButtonsInitialized || hideScrollButtonsState
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className={cx('scroll-bar', className)} data-test={dataTest}>
|
|
148
|
+
<ScrollButton
|
|
149
|
+
disabled={scrolledToStart}
|
|
150
|
+
onClick={this.scrollLeft}
|
|
151
|
+
hidden={hideScrollButtons}
|
|
152
|
+
>
|
|
153
|
+
<IconChevronLeft16 />
|
|
154
|
+
</ScrollButton>
|
|
155
|
+
|
|
156
|
+
<div className="scroll-box-clipper">
|
|
157
|
+
<div className="scroll-box" ref={this.scrollBox}>
|
|
158
|
+
<div className="scroll-area" ref={this.scrollArea}>
|
|
159
|
+
{children}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<ScrollButton
|
|
165
|
+
disabled={scrolledToEnd}
|
|
166
|
+
onClick={this.scrollRight}
|
|
167
|
+
hidden={hideScrollButtons}
|
|
168
|
+
>
|
|
169
|
+
<IconChevronRight16 />
|
|
170
|
+
</ScrollButton>
|
|
171
|
+
|
|
172
|
+
<style jsx>{`
|
|
173
|
+
.scroll-box {
|
|
174
|
+
margin-bottom: ${-this.horizontalScrollBarHeight}px;
|
|
175
|
+
}
|
|
176
|
+
`}</style>
|
|
177
|
+
|
|
178
|
+
<style jsx>{`
|
|
179
|
+
.scroll-bar {
|
|
180
|
+
display: flex;
|
|
181
|
+
flex-wrap: nowrap;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.scroll-box-clipper {
|
|
185
|
+
overflow-y: hidden;
|
|
186
|
+
flex-grow: 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.scroll-box {
|
|
190
|
+
flex-grow: 1;
|
|
191
|
+
overflow-x: scroll;
|
|
192
|
+
-webkit-overflow-scrolling: touch;
|
|
193
|
+
display: -ms-flexbox;
|
|
194
|
+
display: flex;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.scroll-box::-webkit-scrollbar {
|
|
198
|
+
display: none;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.scroll-area {
|
|
202
|
+
position: relative;
|
|
203
|
+
display: flex;
|
|
204
|
+
flex: 1 0 auto;
|
|
205
|
+
overflow-x: hidden;
|
|
206
|
+
}
|
|
207
|
+
`}</style>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
ScrollBar.propTypes = {
|
|
214
|
+
children: PropTypes.node.isRequired,
|
|
215
|
+
dataTest: PropTypes.string.isRequired,
|
|
216
|
+
className: PropTypes.string,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export { ScrollBar }
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { colors } from '@dhis2/ui-constants'
|
|
2
|
+
import cx from 'classnames'
|
|
3
|
+
import PropTypes from 'prop-types'
|
|
4
|
+
import React from 'react'
|
|
5
|
+
|
|
6
|
+
export const ScrollButton = ({ children, disabled, hidden, onClick }) => (
|
|
7
|
+
<button
|
|
8
|
+
onClick={disabled ? undefined : onClick}
|
|
9
|
+
className={cx('scroll-button', { disabled, hidden })}
|
|
10
|
+
>
|
|
11
|
+
{children}
|
|
12
|
+
|
|
13
|
+
<style jsx>{`
|
|
14
|
+
.scroll-button {
|
|
15
|
+
display: inline-flex;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
align-items: center;
|
|
18
|
+
|
|
19
|
+
color: inherit;
|
|
20
|
+
background-color: ${colors.white};
|
|
21
|
+
border: none;
|
|
22
|
+
border-bottom: 1px solid ${colors.grey400};
|
|
23
|
+
outline: none;
|
|
24
|
+
padding: 16px 16px 11px 16px;
|
|
25
|
+
|
|
26
|
+
cursor: pointer;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.scroll-button.hidden {
|
|
30
|
+
display: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.scroll-button :global(svg) {
|
|
34
|
+
width: 20px;
|
|
35
|
+
height: 20px;
|
|
36
|
+
fill: ${colors.grey600};
|
|
37
|
+
transition: opacity 150ms ease-in-out;
|
|
38
|
+
opacity: 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.scroll-button:hover {
|
|
42
|
+
background-color: ${colors.grey100};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.scroll-button:active {
|
|
46
|
+
/* Briefly highlight clicked button */
|
|
47
|
+
background-color: ${colors.grey200};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.scroll-button.disabled {
|
|
51
|
+
cursor: not-allowed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.scroll-button.disabled:hover {
|
|
55
|
+
background-color: transparent;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.scroll-button.disabled :global(svg) {
|
|
59
|
+
fill: ${colors.grey500};
|
|
60
|
+
}
|
|
61
|
+
`}</style>
|
|
62
|
+
</button>
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
ScrollButton.displayName = 'ScrollButton'
|
|
66
|
+
|
|
67
|
+
ScrollButton.propTypes = {
|
|
68
|
+
children: PropTypes.any.isRequired,
|
|
69
|
+
disabled: PropTypes.bool,
|
|
70
|
+
hidden: PropTypes.bool,
|
|
71
|
+
onClick: PropTypes.func,
|
|
72
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Tab } from '../tab/index.js'
|
|
3
|
+
import { TabBar } from './index.js'
|
|
4
|
+
|
|
5
|
+
export default { title: 'TabBar' }
|
|
6
|
+
export const WithChildren = () => <TabBar>I am a child</TabBar>
|
|
7
|
+
export const ScrollableWithChildren = () => (
|
|
8
|
+
<TabBar scrollable>I am a child</TabBar>
|
|
9
|
+
)
|
|
10
|
+
export const ScrollableWithSomeTabs = () => {
|
|
11
|
+
const TabStaticWidth = () => (
|
|
12
|
+
<Tab>
|
|
13
|
+
<div
|
|
14
|
+
style={{
|
|
15
|
+
width: 100,
|
|
16
|
+
border: '1px solid black',
|
|
17
|
+
}}
|
|
18
|
+
>
|
|
19
|
+
Foo
|
|
20
|
+
</div>
|
|
21
|
+
</Tab>
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<TabBar scrollable>
|
|
26
|
+
<TabStaticWidth />
|
|
27
|
+
<TabStaticWidth />
|
|
28
|
+
<TabStaticWidth />
|
|
29
|
+
</TabBar>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import PropTypes from 'prop-types'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { ScrollBar } from './scroll-bar.js'
|
|
4
|
+
import { Tabs } from './tabs.js'
|
|
5
|
+
|
|
6
|
+
const TabBar = ({
|
|
7
|
+
fixed,
|
|
8
|
+
children,
|
|
9
|
+
className,
|
|
10
|
+
scrollable,
|
|
11
|
+
dataTest = 'dhis2-uicore-tabbar',
|
|
12
|
+
}) => {
|
|
13
|
+
if (scrollable) {
|
|
14
|
+
return (
|
|
15
|
+
<div className={className} data-test={dataTest}>
|
|
16
|
+
<ScrollBar dataTest={`${dataTest}-scrollbar`}>
|
|
17
|
+
<Tabs fixed={fixed} dataTest={`${dataTest}-tabs`}>
|
|
18
|
+
{children}
|
|
19
|
+
</Tabs>
|
|
20
|
+
</ScrollBar>
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className={className} data-test={dataTest}>
|
|
27
|
+
<Tabs fixed={fixed} dataTest={`${dataTest}-tabs`}>
|
|
28
|
+
{children}
|
|
29
|
+
</Tabs>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
TabBar.propTypes = {
|
|
35
|
+
children: PropTypes.node,
|
|
36
|
+
className: PropTypes.string,
|
|
37
|
+
dataTest: PropTypes.string,
|
|
38
|
+
/** Fixed tabs fill the width of their container. If false (i.e. fluid), tabs take up an amount of space defined by the tab name. Fluid tabs should be used most of the time. */
|
|
39
|
+
fixed: PropTypes.bool,
|
|
40
|
+
/** Enables horizontal scrolling for many tabs that don't fit the width of the container */
|
|
41
|
+
scrollable: PropTypes.bool,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { TabBar }
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import PropTypes from 'prop-types'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { Tab } from '../tab/index.js'
|
|
4
|
+
import { TabBar } from './index.js'
|
|
5
|
+
|
|
6
|
+
function AttachFile({ className }) {
|
|
7
|
+
return (
|
|
8
|
+
<svg
|
|
9
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
10
|
+
viewBox="0 0 48 48"
|
|
11
|
+
className={className}
|
|
12
|
+
>
|
|
13
|
+
<path d="M33 12v23c0 4.42-3.58 8-8 8s-8-3.58-8-8V10c0-2.76 2.24-5 5-5s5 2.24 5 5v21c0 1.1-.89 2-2 2-1.11 0-2-.9-2-2V12h-3v19c0 2.76 2.24 5 5 5s5-2.24 5-5V10c0-4.42-3.58-8-8-8s-8 3.58-8 8v25c0 6.08 4.93 11 11 11s11-4.92 11-11V12h-3z" />
|
|
14
|
+
<style jsx>{`
|
|
15
|
+
svg {
|
|
16
|
+
fill: inherit;
|
|
17
|
+
height: 24px;
|
|
18
|
+
width: 24px;
|
|
19
|
+
vertical-align: middle;
|
|
20
|
+
pointer-events: none;
|
|
21
|
+
}
|
|
22
|
+
`}</style>
|
|
23
|
+
</svg>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
AttachFile.propTypes = {
|
|
28
|
+
className: PropTypes.string,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const subtitle = 'Ssed to divide content into categories and/or sections'
|
|
32
|
+
|
|
33
|
+
const description = `
|
|
34
|
+
Use tabs to split related content into separate sections.
|
|
35
|
+
|
|
36
|
+
- Each tab should contain content that relates to one another, but the content should not overlap.
|
|
37
|
+
- Tabs are especially useful for splitting up content that may be relevant to different user groups, instead of displaying overwhelming amounts of information on a single page.
|
|
38
|
+
- Do not use tabs to compare content.
|
|
39
|
+
- Do not use tabs for sequential content that needs to be done in order.
|
|
40
|
+
- Do not use tabs for content that needs to be viewed at the same time.
|
|
41
|
+
- The number of tabs is less important than splitting content into understandable, predictable groups. Do not group together unrelated content in order to reduce tab count. Users struggle more with unpredictable tabs than too many tabs.
|
|
42
|
+
|
|
43
|
+
#### Naming
|
|
44
|
+
|
|
45
|
+
Give tabs short, understandable names. Try to find a word or very short phrase that summarizes the content. If you cannot find a suitable word this may mean you are trying to fit too much content under a single tab. The content of a tab should be obvious from its name.
|
|
46
|
+
|
|
47
|
+
For example: Do use "Legends" instead of "Set up legends", Do use "Data analysis" instead of "Options for analysis of data",
|
|
48
|
+
|
|
49
|
+
Do not repeat a term across tabs. If tabs are used inside a 'Options' modal, it is enough to use tab names "Data", "Legend", "Style". Do not repeat 'options' for all, e.g. "Data options", "Legend options" etc.
|
|
50
|
+
|
|
51
|
+
\`\`\`js
|
|
52
|
+
import { TabBar, Tab } from '@dhis2/ui'
|
|
53
|
+
\`\`\`
|
|
54
|
+
`
|
|
55
|
+
|
|
56
|
+
const Wrapper = (fn) => (
|
|
57
|
+
<div
|
|
58
|
+
style={{
|
|
59
|
+
maxWidth: 700,
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{fn()}
|
|
63
|
+
<p>Max-width of this container is 700 px</p>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
window.onClick = (payload, event) => {
|
|
68
|
+
console.log('onClick payload', payload)
|
|
69
|
+
console.log('onClick event', event)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const onClick = (...args) => window.onClick(...args)
|
|
73
|
+
|
|
74
|
+
export default {
|
|
75
|
+
title: 'Tab Bar',
|
|
76
|
+
component: TabBar,
|
|
77
|
+
subcomponents: { Tab },
|
|
78
|
+
parameters: {
|
|
79
|
+
componentSubtitle: subtitle,
|
|
80
|
+
docs: { description: { component: description } },
|
|
81
|
+
},
|
|
82
|
+
decorators: [Wrapper],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const DefaultFluid = (args) => (
|
|
86
|
+
<TabBar {...args}>
|
|
87
|
+
<Tab onClick={onClick}>Tab A</Tab>
|
|
88
|
+
<Tab onClick={onClick}>Tab B</Tab>
|
|
89
|
+
<Tab onClick={onClick} selected>
|
|
90
|
+
Tab C
|
|
91
|
+
</Tab>
|
|
92
|
+
<Tab onClick={onClick}>Tab D</Tab>
|
|
93
|
+
<Tab onClick={onClick}>Tab E</Tab>
|
|
94
|
+
<Tab onClick={onClick}>Tab F</Tab>
|
|
95
|
+
<Tab onClick={onClick}>Tab G</Tab>
|
|
96
|
+
</TabBar>
|
|
97
|
+
)
|
|
98
|
+
DefaultFluid.storyName = 'Default (fluid)'
|
|
99
|
+
|
|
100
|
+
export const FixedTabsFillContent = (args) => (
|
|
101
|
+
<TabBar {...args}>
|
|
102
|
+
<Tab onClick={onClick}>Tab A</Tab>
|
|
103
|
+
<Tab onClick={onClick}>Tab B</Tab>
|
|
104
|
+
<Tab onClick={onClick} selected>
|
|
105
|
+
Tab C
|
|
106
|
+
</Tab>
|
|
107
|
+
<Tab onClick={onClick}>Tab D</Tab>
|
|
108
|
+
<Tab onClick={onClick}>Tab E</Tab>
|
|
109
|
+
<Tab onClick={onClick}>Tab F</Tab>
|
|
110
|
+
<Tab onClick={onClick}>Tab G</Tab>
|
|
111
|
+
</TabBar>
|
|
112
|
+
)
|
|
113
|
+
FixedTabsFillContent.args = { fixed: true }
|
|
114
|
+
FixedTabsFillContent.storyName = 'Fixed - tabs fill content'
|
|
115
|
+
|
|
116
|
+
export const TabsWithScroller = (args) => (
|
|
117
|
+
<TabBar {...args}>
|
|
118
|
+
<Tab onClick={onClick}>Tab A</Tab>
|
|
119
|
+
<Tab onClick={onClick}>Tab B</Tab>
|
|
120
|
+
<Tab onClick={onClick}>Tab C</Tab>
|
|
121
|
+
<Tab onClick={onClick}>Tab D</Tab>
|
|
122
|
+
<Tab onClick={onClick}>Tab E</Tab>
|
|
123
|
+
<Tab onClick={onClick}>Tab F</Tab>
|
|
124
|
+
<Tab onClick={onClick}>Tab G</Tab>
|
|
125
|
+
<Tab onClick={onClick}>Tab H</Tab>
|
|
126
|
+
<Tab onClick={onClick}>Tab I</Tab>
|
|
127
|
+
<Tab onClick={onClick}>Tab J</Tab>
|
|
128
|
+
<Tab onClick={onClick}>Tab K</Tab>
|
|
129
|
+
<Tab onClick={onClick}>Tab L</Tab>
|
|
130
|
+
<Tab onClick={onClick} selected>
|
|
131
|
+
Tab M
|
|
132
|
+
</Tab>
|
|
133
|
+
<Tab onClick={onClick}>Tab N</Tab>
|
|
134
|
+
<Tab onClick={onClick}>Tab O</Tab>
|
|
135
|
+
<Tab onClick={onClick}>Tab P</Tab>
|
|
136
|
+
<Tab onClick={onClick}>Tab Q</Tab>
|
|
137
|
+
<Tab onClick={onClick}>Tab R</Tab>
|
|
138
|
+
</TabBar>
|
|
139
|
+
)
|
|
140
|
+
TabsWithScroller.args = { scrollable: true }
|
|
141
|
+
TabsWithScroller.storyName = 'Tabs with scroller'
|
|
142
|
+
|
|
143
|
+
export const TabsWithHiddenScrollButtons = (args) => (
|
|
144
|
+
<TabBar {...args}>
|
|
145
|
+
<Tab onClick={onClick}>Tab A</Tab>
|
|
146
|
+
<Tab onClick={onClick}>Tab B</Tab>
|
|
147
|
+
<Tab onClick={onClick}>Tab C</Tab>
|
|
148
|
+
</TabBar>
|
|
149
|
+
)
|
|
150
|
+
TabsWithHiddenScrollButtons.args = { scrollable: true }
|
|
151
|
+
TabsWithHiddenScrollButtons.storyName = 'Tabs with hidden scroll buttons'
|
|
152
|
+
|
|
153
|
+
export const TabStates = (args) => (
|
|
154
|
+
<TabBar {...args}>
|
|
155
|
+
<Tab onClick={onClick}>Default</Tab>
|
|
156
|
+
<Tab onClick={onClick} selected>
|
|
157
|
+
Selected
|
|
158
|
+
</Tab>
|
|
159
|
+
<Tab disabled>Disabled</Tab>
|
|
160
|
+
<Tab onClick={onClick}>
|
|
161
|
+
Text overflow - This tab has a very long text and it exceeds the
|
|
162
|
+
maximum width of 320px
|
|
163
|
+
</Tab>
|
|
164
|
+
</TabBar>
|
|
165
|
+
)
|
|
166
|
+
TabStates.storyName = 'Tab states'
|
|
167
|
+
|
|
168
|
+
export const TabStatesWithIcon = (args) => (
|
|
169
|
+
<TabBar {...args}>
|
|
170
|
+
<Tab onClick={onClick} icon={<AttachFile />}>
|
|
171
|
+
Default
|
|
172
|
+
</Tab>
|
|
173
|
+
<Tab onClick={onClick} icon={<AttachFile />} selected>
|
|
174
|
+
Selected
|
|
175
|
+
</Tab>
|
|
176
|
+
<Tab icon={<AttachFile />} disabled>
|
|
177
|
+
Disabled
|
|
178
|
+
</Tab>
|
|
179
|
+
<Tab onClick={onClick} icon={<AttachFile />}>
|
|
180
|
+
Text overflow - This tab has a very long text and it exceeds the
|
|
181
|
+
maximum width of 320px
|
|
182
|
+
</Tab>
|
|
183
|
+
</TabBar>
|
|
184
|
+
)
|
|
185
|
+
TabStatesWithIcon.storyName = 'Tab states - with icon'
|
|
186
|
+
|
|
187
|
+
export const RTL = (args) => (
|
|
188
|
+
<div dir="rtl">
|
|
189
|
+
<TabStatesWithIcon {...args} />
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { colors } from '@dhis2/ui-constants'
|
|
2
|
+
import cx from 'classnames'
|
|
3
|
+
import PropTypes from 'prop-types'
|
|
4
|
+
import React, { useRef, useMemo } from 'react'
|
|
5
|
+
|
|
6
|
+
const Tabs = ({ children, fixed, dataTest }) => {
|
|
7
|
+
const tabContainer = useRef(null)
|
|
8
|
+
|
|
9
|
+
const childrenRefs = useMemo(
|
|
10
|
+
() => React.Children.map(children, () => React.createRef()),
|
|
11
|
+
[children]
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const handleKeyDown = (event) => {
|
|
15
|
+
const currentFocus = document.activeElement
|
|
16
|
+
|
|
17
|
+
if (tabContainer.current && tabContainer.current === currentFocus) {
|
|
18
|
+
if (childrenRefs.length > 0 && childrenRefs[0].current) {
|
|
19
|
+
childrenRefs[0].current.focus()
|
|
20
|
+
}
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const currentIndex = childrenRefs.findIndex(
|
|
25
|
+
(ref) => ref.current === currentFocus
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if (currentIndex === -1) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (event.key === 'ArrowRight') {
|
|
33
|
+
event.preventDefault()
|
|
34
|
+
const nextIndex = (currentIndex + 1) % childrenRefs.length
|
|
35
|
+
childrenRefs[nextIndex].current.focus()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (event.key === 'ArrowLeft') {
|
|
39
|
+
event.preventDefault()
|
|
40
|
+
const prevIndex =
|
|
41
|
+
(currentIndex - 1 + childrenRefs.length) % childrenRefs.length
|
|
42
|
+
childrenRefs[prevIndex].current.focus()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
className={cx({ fixed })}
|
|
49
|
+
ref={tabContainer}
|
|
50
|
+
data-test={dataTest}
|
|
51
|
+
role="tablist"
|
|
52
|
+
tabIndex={0}
|
|
53
|
+
onKeyDown={handleKeyDown}
|
|
54
|
+
>
|
|
55
|
+
{React.Children.map(children, (child, index) => {
|
|
56
|
+
if (React.isValidElement(child)) {
|
|
57
|
+
return React.cloneElement(child, {
|
|
58
|
+
ref: childrenRefs[index],
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
// Wrap non-element children e.g string in a <span>
|
|
62
|
+
return (
|
|
63
|
+
<span ref={childrenRefs[index]} tabIndex={-1}>
|
|
64
|
+
{child}
|
|
65
|
+
</span>
|
|
66
|
+
)
|
|
67
|
+
})}
|
|
68
|
+
|
|
69
|
+
<style jsx>{`
|
|
70
|
+
div {
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: flex-start;
|
|
73
|
+
position: relative;
|
|
74
|
+
overflow: hidden;
|
|
75
|
+
box-shadow: inset 0 -1px 0 0 ${colors.grey400};
|
|
76
|
+
flex-wrap: nowrap;
|
|
77
|
+
flex-grow: 1;
|
|
78
|
+
background: ${colors.white};
|
|
79
|
+
}
|
|
80
|
+
`}</style>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Tabs.propTypes = {
|
|
86
|
+
dataTest: PropTypes.string.isRequired,
|
|
87
|
+
children: PropTypes.node,
|
|
88
|
+
fixed: PropTypes.bool,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { Tabs }
|