@dhis2-ui/tab 10.16.2 → 10.16.3-alpha.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dhis2-ui/tab",
3
- "version": "10.16.2",
3
+ "version": "10.16.3-alpha.1",
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.2",
35
+ "@dhis2-ui/tooltip": "10.16.3-alpha.1",
36
36
  "@dhis2/prop-types": "^3.1.2",
37
- "@dhis2/ui-constants": "10.16.2",
38
- "@dhis2/ui-icons": "10.16.2",
37
+ "@dhis2/ui-constants": "10.16.3-alpha.1",
38
+ "@dhis2/ui-icons": "10.16.3-alpha.1",
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,2 @@
1
+ export { Tab } from './tab/index.js'
2
+ export { TabBar } from './tab-bar/index.js'
@@ -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,5 @@
1
+ Feature: The Tab renders children
2
+
3
+ Scenario: A Tab with children
4
+ Given a Tab with children is rendered
5
+ Then the children are visible
@@ -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,5 @@
1
+ Feature: The Tab renders icon
2
+
3
+ Scenario: A Tab with an icon
4
+ Given a Tab with an icon is rendered
5
+ Then the icon is visible
@@ -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
@@ -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 }