From 4aedf382d57169c0502965996a196fc7ace58410 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Tue, 20 Aug 2019 15:58:17 -0400 Subject: [PATCH] feat: Collapsible Component (#504) Refactor of the Collapsible Component. It is now a composable component and set of subcomponents. BREAKING CHANGE: The prop api has changed significantly to simplify usages and enable flexibility. See the docs for details. BREAKING CHANGE: Related styles are included in paragon scss and no longer exist as a sibling to the component. --- .../__snapshots__/Storyshots.test.js.snap | 206 ++----------- package-lock.json | 69 ++--- package.json | 4 +- scss/core/_variables.scss | 7 + scss/core/extensions/_collapsible.scss | 55 ++++ src/Collapsible/Collapsible.scss | 56 ---- src/Collapsible/Collapsible.stories.jsx | 59 +--- src/Collapsible/Collapsible.test.jsx | 286 +++++++++--------- src/Collapsible/CollapsibleAdvanced.jsx | 111 +++++++ src/Collapsible/CollapsibleBody.jsx | 38 +++ src/Collapsible/CollapsibleTrigger.jsx | 71 +++++ src/Collapsible/CollapsibleVisible.jsx | 32 ++ src/Collapsible/README.md | 42 --- .../__snapshots__/Collapsible.test.jsx.snap | 208 +++++++++++++ src/Collapsible/index.jsx | 198 ++++-------- src/Dropdown/index.jsx | 1 - src/index.scss | 2 +- www/gatsby-node.js | 8 + www/src/pages/components/collapsible.mdx | 224 ++++++++++++-- www/src/scss/_code-block.scss | 2 +- www/src/scss/index.scss | 12 +- 21 files changed, 1010 insertions(+), 681 deletions(-) create mode 100644 scss/core/extensions/_collapsible.scss delete mode 100644 src/Collapsible/Collapsible.scss create mode 100644 src/Collapsible/CollapsibleAdvanced.jsx create mode 100644 src/Collapsible/CollapsibleBody.jsx create mode 100644 src/Collapsible/CollapsibleTrigger.jsx create mode 100644 src/Collapsible/CollapsibleVisible.jsx delete mode 100644 src/Collapsible/README.md create mode 100644 src/Collapsible/__snapshots__/Collapsible.test.jsx.snap diff --git a/.storybook/__snapshots__/Storyshots.test.js.snap b/.storybook/__snapshots__/Storyshots.test.js.snap index f7fa86b127..8f5f29eac6 100644 --- a/.storybook/__snapshots__/Storyshots.test.js.snap +++ b/.storybook/__snapshots__/Storyshots.test.js.snap @@ -382,214 +382,58 @@ Array [ ] `; -exports[`Storyshots Collapsible basic usage with resizing 1`] = ` -
-
-

- Try resizing the screen to medium or small -

-
-
-

- You can fit lots of things in here -

-
    -
  • - 1 thing -
  • -
  • - 2 things -
  • -
  • - 3 things -
  • -
-
-
-
-
-`; - -exports[`Storyshots Collapsible basic usage without resizing 1`] = ` +exports[`Storyshots Collapsible usage 1`] = `
-
-

- Your stuff goes here -

-
-
-`; - -exports[`Storyshots Collapsible fires onToggle callback when toggled 1`] = ` -
- -
-

- Your stuff goes here -

+
-
-`; - -exports[`Storyshots Collapsible initially open collapsible 1`] = ` -
-
-

- Your stuff goes here -

-
-
-`; - -exports[`Storyshots Collapsible with custom icon 1`] = ` -
- -
-

- Your stuff goes here -

`; diff --git a/package-lock.json b/package-lock.json index fdb128f5e7..d4c1a669e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1409,24 +1409,24 @@ "dev": true }, "@fortawesome/fontawesome-common-types": { - "version": "0.2.19", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.19.tgz", - "integrity": "sha512-nd2Ul/CUs8U9sjofQYAALzOGpgkVJQgEhIJnOHaoyVR/LeC3x2mVg4eB910a4kS6WgLPebAY0M2fApEI497raQ==" + "version": "0.2.21", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.21.tgz", + "integrity": "sha512-iJtcrU2BtF9Wyr0zm3tHEJy3HqA6sADExhCqCv3SKaJJKKp4ORJ40t4nyHvcWXSVFtd7r1gcdqcRsAfoREGTFA==" }, "@fortawesome/fontawesome-svg-core": { - "version": "1.2.19", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.19.tgz", - "integrity": "sha512-D4ICXg9oU08eF9o7Or392gPpjmwwgJu8ecCFusthbID95CLVXOgIyd4mOKD9Nud5Ckz+Ty59pqkNtThDKR0erA==", + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.21.tgz", + "integrity": "sha512-EhrgMZLJS0tTYZhUbodurZBqDgAFLDNdxJP/q5unrZJwiFo8Dd7xGvJdhAhY5WcX4khzkPQcbLTCMPHBtutD7Q==", "requires": { - "@fortawesome/fontawesome-common-types": "^0.2.19" + "@fortawesome/fontawesome-common-types": "^0.2.21" } }, "@fortawesome/free-solid-svg-icons": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.9.0.tgz", - "integrity": "sha512-U8YXPfWcSozsCW0psCtlRGKjjRs5+Am5JJwLOUmVHFZbIEWzaz4YbP84EoPwUsVmSAKrisu3QeNcVOtmGml0Xw==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.10.1.tgz", + "integrity": "sha512-MKH+SCt0DnVoXdemxf6JEdTRtCPwYLMCWZcwgGccYU/ab6QcDtbAMn6Xm4Zub6YqQCcaiy0hU294YdHOldSBRA==", "requires": { - "@fortawesome/fontawesome-common-types": "^0.2.19" + "@fortawesome/fontawesome-common-types": "^0.2.21" } }, "@fortawesome/react-fontawesome": { @@ -9092,8 +9092,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -9114,14 +9113,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -9136,20 +9133,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -9266,8 +9260,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -9279,7 +9272,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -9294,7 +9286,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -9302,14 +9293,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -9328,7 +9317,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -9409,8 +9397,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -9422,7 +9409,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -9508,8 +9494,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -9545,7 +9530,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -9565,7 +9549,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -9609,14 +9592,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -13622,7 +13603,7 @@ "dependencies": { "semver": { "version": "5.3.0", - "resolved": "http://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", "dev": true } @@ -13788,7 +13769,7 @@ }, "chalk": { "version": "1.1.3", - "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "dev": true, "requires": { @@ -20341,7 +20322,7 @@ }, "os-locale": { "version": "1.4.0", - "resolved": "http://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", "dev": true, "requires": { diff --git a/package.json b/package.json index f3a752d3a1..8bdcd57dba 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,8 @@ "travis-deploy-once": "travis-deploy-once" }, "dependencies": { - "@fortawesome/fontawesome-svg-core": "^1.2.18", - "@fortawesome/free-solid-svg-icons": "^5.8.2", + "@fortawesome/fontawesome-svg-core": "^1.2.21", + "@fortawesome/free-solid-svg-icons": "^5.10.1", "@fortawesome/react-fontawesome": "^0.1.4", "airbnb-prop-types": "^2.12.0", "bootstrap": "^4.3.1", diff --git a/scss/core/_variables.scss b/scss/core/_variables.scss index cfaa047948..17e9fd8fb6 100644 --- a/scss/core/_variables.scss +++ b/scss/core/_variables.scss @@ -754,6 +754,13 @@ $card-columns-gap: 1.25rem !default; $card-columns-margin: $card-spacer-y !default; +// Collapsible + +$collapsible-card-spacer-y: .45rem !default; +$collapsible-card-spacer-x: .75rem !default; +$collapsible-card-spacer-y-lg: $card-spacer-y !default; +$collapsible-card-spacer-x-lg: $card-spacer-x !default; + // Tooltips $tooltip-font-size: $font-size-sm !default; diff --git a/scss/core/extensions/_collapsible.scss b/scss/core/extensions/_collapsible.scss new file mode 100644 index 0000000000..0ebb02176f --- /dev/null +++ b/scss/core/extensions/_collapsible.scss @@ -0,0 +1,55 @@ +.collapsible-card { + @extend .card; + + .collapsible-trigger { + padding: $collapsible-card-spacer-y $collapsible-card-spacer-x; + border-radius: $card-inner-border-radius; + border-bottom: $card-border-width solid transparent; + transition: border-color 100ms ease 150ms; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + + & > * { + margin-bottom: 0; + margin-top: 0; + } + + &[aria-expanded="true"] { + border-radius: $card-inner-border-radius $card-inner-border-radius 0 0; + border-color: $card-border-color; + transition: none; + } + } + + .collapsible-body { + @extend .card-body; + padding: $collapsible-card-spacer-y $collapsible-card-spacer-x; + & > *:last-child { + margin-bottom: 0; + } + } +} + +.collapsible-card-lg { + @extend .collapsible-card; + + .collapsible-trigger { + padding: $collapsible-card-spacer-y-lg $collapsible-card-spacer-x-lg; + } + + .collapsible-body { + padding: $collapsible-card-spacer-y-lg $collapsible-card-spacer-x-lg; + } +} + +.collapsible-basic { + .collapsible-trigger { + display: flex; + cursor: pointer; + align-items: center; + text-decoration: underline; + color: theme-color('primary', 'default'); + } +} diff --git a/src/Collapsible/Collapsible.scss b/src/Collapsible/Collapsible.scss deleted file mode 100644 index f91d2778ef..0000000000 --- a/src/Collapsible/Collapsible.scss +++ /dev/null @@ -1,56 +0,0 @@ -// Local Variables -$collapsible-border: 1px solid silver; - -.collapsible { - border-radius: 4px; - border: 1px solid transparent; - transition: border 0.3s ease; - - .btn-collapsible { - border: $collapsible-border; - white-space: normal; - background-color: white; - - &:hover { - background-color: #f5f8ff; - } - } - - .btn-collapsible.open { - border: 1px solid transparent; - } - - .collapsible-body { - visibility: collapse; - opacity: 0; - overflow: hidden; - padding: 0 15px; - max-height: 0; - transition: all 0.3s ease; - } - - &.open { - border: $collapsible-border; - - .collapsible-body { - visibility: visible; - opacity: 1; - max-height: 99999px; // Set to a very high number since we don't know how large the body will be - padding: 15px; - } - - .btn-collapsible { - background-color: white; - } - } - - &.expanded { - .collapsible-body { - visibility: visible; - opacity: 1; - max-height: none; - padding: 0; - transition: none; - } - } -} diff --git a/src/Collapsible/Collapsible.stories.jsx b/src/Collapsible/Collapsible.stories.jsx index 41f28ed1a0..48f9109d72 100644 --- a/src/Collapsible/Collapsible.stories.jsx +++ b/src/Collapsible/Collapsible.stories.jsx @@ -1,60 +1,19 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { faChevronCircleDown, faChevronCircleUp } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; - -import README from './README.md'; import Collapsible from './index'; -import { breakpoints } from '../Responsive'; storiesOf('Collapsible', module) - .addParameters({ info: { text: README } }) - .add('basic usage without resizing', () => ( - -

Your stuff goes here

-
- )) - .add('basic usage with resizing', () => ( -
- Try resizing the screen to medium or small} - title="Try resizing the screen to large" - isCollapsible={() => global.innerWidth >= breakpoints.large.minWidth || - global.matchMedia(`(min-width: ${breakpoints.large.minWidth}px)`).matches} - > -
-

You can fit lots of things in here

-
    -
  • 1 thing
  • -
  • 2 things
  • -
  • 3 things
  • -
-
-
-
- )) - .add('initially open collapsible', () => ( - -

Your stuff goes here

-
- )) - .add('fires onToggle callback when toggled', () => ( + .add('usage', () => ( console.log(`this.state.isOpen = ${isOpen}`)} // eslint-disable-line no-console + onToggle={isOpen => console.log('Collapsible toggled and open is: ', isOpen)} + onOpen={() => console.log('Collapsible opened.')} + onClose={() => console.log('Collapsible closed.')} > -

Your stuff goes here

-
- )) - .add('with custom icon', () => ( - , - collapsed: , - }} - > -

Your stuff goes here

+

Your stuff goes here.

+ + + Close +
)); diff --git a/src/Collapsible/Collapsible.test.jsx b/src/Collapsible/Collapsible.test.jsx index 6afb224efc..786123bbca 100644 --- a/src/Collapsible/Collapsible.test.jsx +++ b/src/Collapsible/Collapsible.test.jsx @@ -1,173 +1,187 @@ import React from 'react'; import { mount } from 'enzyme'; -import { faChevronCircleUp, faChevronCircleDown } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import renderer from 'react-test-renderer'; +import Collapsible from '../Collapsible'; -import { breakpoints } from '../Responsive'; -import '../__mocks__/reactResponsive.mock'; -import Collapsible from '../Collapsible'; +const collapsibleContent = ( + + +

A heading

+ + + + + - + +
+ + Close + Open -const childElements = ( -
-

Child

-
+ }> +

+ Example content +

+
+
); -const defaultProps = { - title: 'Collapsible', - children: childElements, +const collapsibleIsOpen = (wrapper) => { + expect(wrapper.find('.example-content').length).toEqual(1); }; -describe('', () => { - describe('without resizing', () => { - describe('correct rendering', () => { - it('renders in closed form by default', () => { - const wrapper = mount(); +const collapsibleIsClosed = (wrapper) => { + expect(wrapper.find('.example-content').length).toEqual(0); +}; - expect(wrapper.find('.collapsible').exists()).toEqual(true); - expect(wrapper.find('.collapsible.open').exists()).toEqual(false); - expect(wrapper.find('.btn-collapsible').exists()).toEqual(true); - expect(wrapper.find('.btn-collapsible.open').exists()).toEqual(false); - expect(wrapper.find('button').prop('aria-expanded')).toEqual(false); +describe('', () => { + describe('Uncontrolled Rendering', () => { + it('renders closed by default', () => { + const tree = renderer.create(( + + {collapsibleContent} + + )).toJSON(); + expect(tree).toMatchSnapshot(); + }); + it('renders open by default', () => { + const tree = renderer.create(( + + {collapsibleContent} + + )).toJSON(); + expect(tree).toMatchSnapshot(); + }); + }); - expect(wrapper.find('.collapsible-body.open').exists()).toEqual(false); - }); + describe('Controlled Rendering', () => { + it('renders closed by default', () => { + const tree = renderer.create(( + + {collapsibleContent} + + )).toJSON(); + expect(tree).toMatchSnapshot(); + }); + it('renders open by default', () => { + const tree = renderer.create(( + + {collapsibleContent} + + )).toJSON(); + expect(tree).toMatchSnapshot(); + }); + }); - it('renders in open form if specified open', () => { - const wrapper = mount(); + describe('Imperative Methods', () => { + const wrapper = mount({collapsibleContent}); + const collapsible = wrapper.instance(); - expect(wrapper.find('.collapsible.open').exists()).toEqual(true); - expect(wrapper.find('.btn-collapsible.open').exists()).toEqual(true); - expect(wrapper.find('button').prop('aria-expanded')).toEqual(true); - expect(wrapper.find('.collapsible-body.open').exists()).toEqual(true); - }); + collapsibleIsClosed(wrapper); - it('changes the isOpen state if the isOpen prop changes', () => { - const wrapper = mount(); - expect(wrapper.instance().state.isOpen).toBe(true); - wrapper.setProps({ isOpen: false }); - expect(wrapper.instance().state.isOpen).toBe(false); - }); + it('opens on .open()', () => { + collapsible.open(); + wrapper.update(); + collapsibleIsOpen(wrapper); + }); + + it('closes on .close()', () => { + collapsible.close(); + wrapper.update(); + collapsibleIsClosed(wrapper); + }); + }); - it('renders the title on the open/close button', () => { - const wrapper = mount(); + describe('Mouse Interactions', () => { + const wrapper = mount({collapsibleContent}); + const collapsible = wrapper.instance(); + const trigger = wrapper.find('.trigger[role="button"]'); + const closeOnlyTrigger = wrapper.find('.close-only[role="button"]'); + const openOnlyTrigger = wrapper.find('.open-only[role="button"]'); - expect(wrapper.find('.collapsible-title').text()).toEqual(defaultProps.title); - }); + it('opens on trigger click', () => { + trigger.simulate('click'); // Open + collapsibleIsOpen(wrapper); }); - it('does not change to expanded form on resizing window', () => { - // Change to a small window and it should show the collapsible button - global.innerWidth = breakpoints.small.minWidth; - let wrapper = mount(); - expect(wrapper.find('.btn-collapsible').exists()).toEqual(true); + it('closes on trigger click', () => { + trigger.simulate('click'); // Close + collapsibleIsClosed(wrapper); + }); - // Change to a large window and it should still show the collapsible button - global.innerWidth = breakpoints.large.minWidth; - wrapper = mount(); - expect(wrapper.find('.btn-collapsible').exists()).toEqual(true); + it('does not open on close only trigger click', () => { + collapsible.close(); + wrapper.update(); + closeOnlyTrigger.simulate('click'); // No-op + collapsibleIsClosed(wrapper); }); - it('open to show the body when the collapsible button is clicked', () => { - const wrapper = mount(); + it('closes on close only trigger click', () => { + collapsible.open(); + wrapper.update(); + closeOnlyTrigger.simulate('click'); // Close + collapsibleIsClosed(wrapper); + }); - expect(wrapper.find('.collapsible-body.open').exists()).toEqual(false); + it('does not close on open only trigger click', () => { + collapsible.open(); + wrapper.update(); + openOnlyTrigger.simulate('click'); // No-op + collapsibleIsOpen(wrapper); + }); - wrapper.find('button').simulate('click'); - expect(wrapper.find('.btn-collapsible.open').exists()).toEqual(true); - expect(wrapper.find('button').prop('aria-expanded')).toEqual(true); - expect(wrapper.find('.collapsible-body.open').exists()).toEqual(true); + it('opens on opens only trigger click', () => { + collapsible.close(); + wrapper.update(); + openOnlyTrigger.simulate('click'); // Open + collapsibleIsOpen(wrapper); }); + }); - it('calls the onToggle callback correctly', () => { - const spy = jest.fn(); - const wrapper = mount(); + describe('Keyboard Interactions', () => { + const wrapper = mount({collapsibleContent}); + const collapsible = wrapper.instance(); + const trigger = wrapper.find('.trigger[role="button"]'); + const closeOnlyTrigger = wrapper.find('.close-only[role="button"]'); + const openOnlyTrigger = wrapper.find('.open-only[role="button"]'); - expect(spy).toHaveBeenCalledTimes(0); + it('opens on trigger enter keydown', () => { + trigger.simulate('keyDown', { key: 'Enter' }); // Open + collapsibleIsOpen(wrapper); + }); - wrapper.find('button').simulate('click'); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(true); + it('closes on trigger enter keydown', () => { + trigger.simulate('keyDown', { key: 'Enter' }); // Close + collapsibleIsClosed(wrapper); + }); - wrapper.find('button').simulate('click'); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith(false); + it('does not open on close only trigger enter keydown', () => { + collapsible.close(); + wrapper.update(); + closeOnlyTrigger.simulate('keyDown', { key: 'Enter' }); // No-op + collapsibleIsClosed(wrapper); }); - it('uses icon elements if they are supplied', () => { - const wrapper = mount(( - , - collapsed: , - }} - /> - )); + it('closes on close only trigger enter keydown', () => { + collapsible.open(); + wrapper.update(); + closeOnlyTrigger.simulate('keyDown', { key: 'Enter' }); // Close + collapsibleIsClosed(wrapper); + }); - expect(wrapper.find('FontAwesomeIcon').prop('icon').iconName).toEqual('chevron-circle-down'); - wrapper.find('.btn-collapsible').first().simulate('click'); - expect(wrapper.find('FontAwesomeIcon').prop('icon').iconName).toEqual('chevron-circle-up'); + it('does not close on open only trigger enter keydown', () => { + collapsible.open(); + wrapper.update(); + openOnlyTrigger.simulate('keyDown', { key: 'Enter' }); // No-op + collapsibleIsOpen(wrapper); }); - }); - describe('with resizing', () => { - const expandFunction = () => global.innerWidth >= breakpoints.large.minWidth; - const expandedTitle =

Collapsible

; - - beforeEach(() => { - global.innerWidth = breakpoints.large.minWidth; - }); - - describe('correct rendering', () => { - it('renders in expanded form without a title by default', () => { - const wrapper = mount(); - - expect(wrapper.find('.expanded-title').exists()).toEqual(false); - expect(wrapper.find('.btn-collapsible').exists()).toEqual(false); - expect(wrapper.find('.collapsible.open').exists()).toEqual(false); - - expect(wrapper.find('.collapsible-body').exists()).toEqual(true); - }); - - it('renders in expanded form with a title if given one', () => { - const wrapper = mount(); - - expect(wrapper.find('.expanded-title').exists()).toEqual(true); - expect(wrapper.find('.collapsible.open').exists()).toEqual(false); - - expect(wrapper.find('.collapsible-body').exists()).toEqual(true); - }); - }); - - it('shows the expanded form for large windows, and the collapsible for smaller windows', () => { - // Change to a small window to view the collapsible button - global.innerWidth = breakpoints.small.minWidth; - let wrapper = mount(); - expect(wrapper.find('.expanded-title').exists()).toEqual(false); - expect(wrapper.find('.btn-collapsible').exists()).toEqual(true); - expect(wrapper.find('.collapsible-body.open').exists()).toEqual(false); - - // Change back to a large window to see the expanded view again - global.innerWidth = breakpoints.large.minWidth; - wrapper = mount(); - expect(wrapper.find('.expanded-title').exists()).toEqual(true); - expect(wrapper.find('.btn-collapsible').exists()).toEqual(false); - expect(wrapper.find('.collapsible-body.open').exists()).toEqual(true); + it('opens on opens only trigger enter keydown', () => { + collapsible.close(); + wrapper.update(); + openOnlyTrigger.simulate('keyDown', { key: 'Enter' }); // Open + collapsibleIsOpen(wrapper); }); }); }); diff --git a/src/Collapsible/CollapsibleAdvanced.jsx b/src/Collapsible/CollapsibleAdvanced.jsx new file mode 100644 index 0000000000..19f97b8243 --- /dev/null +++ b/src/Collapsible/CollapsibleAdvanced.jsx @@ -0,0 +1,111 @@ +import React from 'react'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; + +export const CollapsibleContext = React.createContext(); + +class CollapsibleAdvanced extends React.Component { + static getDerivedStateFromProps(props) { + if (props.open !== undefined) { + return { + // Since this method fires on both props and state changes, local updates + // to the controlled value will be ignored, because the props version + // always overrides it. In this case, this is exactly what we want. + isOpen: props.open, + }; + } + return null; + } + + constructor(props) { + super(props); + + this.state = { + isOpen: props.open !== undefined ? props.open : props.defaultOpen, + }; + } + + open = () => { + this.setState({ isOpen: true }); + + if (this.props.onOpen) { + this.props.onOpen(); + } + } + + close = () => { + this.setState({ isOpen: false }); + + if (this.props.onClose) { + this.props.onClose(); + } + } + + toggle = () => { + if (this.state.isOpen) { + this.close(); + } else { + this.open(); + } + + if (this.props.onToggle) { + this.props.onToggle(this.state.isOpen); + } + } + + render() { + const { + children, + className, + ...props + } = this.props; + + // Unneeded for passthrough props + delete props.defaultOpen; + delete props.onToggle; + delete props.onOpen; + delete props.onClose; + + return ( +
+ + {children} + +
+ ); + } +} + +CollapsibleAdvanced.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + defaultOpen: PropTypes.bool, + open: PropTypes.bool, + onToggle: PropTypes.func, + onOpen: PropTypes.func, + onClose: PropTypes.func, +}; + +CollapsibleAdvanced.defaultProps = { + children: undefined, + className: undefined, + defaultOpen: false, + open: undefined, + onToggle: undefined, + onOpen: undefined, + onClose: undefined, +}; + +export default CollapsibleAdvanced; diff --git a/src/Collapsible/CollapsibleBody.jsx b/src/Collapsible/CollapsibleBody.jsx new file mode 100644 index 0000000000..2c7be8c9c6 --- /dev/null +++ b/src/Collapsible/CollapsibleBody.jsx @@ -0,0 +1,38 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; + +import { CollapsibleContext } from './CollapsibleAdvanced'; +import TransitionReplace from '../TransitionReplace'; + +function CollapsibleBody({ + children, transitionWrapper, tag, ...props +}) { + const { isOpen } = useContext(CollapsibleContext); + + // Keys are added to these elements so that TransitionReplace + // will recognize them as unique components and perform the + // transition properly. + const content = isOpen ? + React.createElement(tag, { key: 'body', ...props }, children) : +
; + + if (transitionWrapper) { + return React.cloneElement(transitionWrapper, {}, content); + } + /* istanbul ignore next */ + return {content}; +} + +CollapsibleBody.propTypes = { + children: PropTypes.node, + tag: PropTypes.string, + transitionWrapper: PropTypes.element, +}; + +CollapsibleBody.defaultProps = { + children: undefined, + tag: 'div', + transitionWrapper: undefined, +}; + +export default CollapsibleBody; diff --git a/src/Collapsible/CollapsibleTrigger.jsx b/src/Collapsible/CollapsibleTrigger.jsx new file mode 100644 index 0000000000..8bb447bf73 --- /dev/null +++ b/src/Collapsible/CollapsibleTrigger.jsx @@ -0,0 +1,71 @@ +import React, { useContext, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +import { CollapsibleContext } from './CollapsibleAdvanced'; + +function CollapsibleTrigger({ + tag, children, openOnly, closeOnly, ...props +}) { + const { + isOpen, open, close, toggle, + } = useContext(CollapsibleContext); + + const handleToggle = (e) => { + if (openOnly) { + open(e); + } else if (closeOnly) { + close(e); + } else { + toggle(e); + } + }; + + const handleClick = useCallback((e) => { + if (props.onClick) { + props.onClick(e); + } + handleToggle(e); + }); + + const handleKeyDown = useCallback((e) => { + if (props.onKeyDown) { + props.onKeyDown(e); + } + if (e.key === 'Enter') { + handleToggle(e); + } + }); + + return React.createElement( + tag, + { + ...props, + onClick: handleClick, + onKeyDown: handleKeyDown, + role: 'button', + tabIndex: 0, + 'aria-expanded': isOpen, + }, + children, + ); +} + +CollapsibleTrigger.propTypes = { + children: PropTypes.node, + tag: PropTypes.string, + openOnly: PropTypes.bool, + closeOnly: PropTypes.bool, + onClick: PropTypes.func, + onKeyDown: PropTypes.func, +}; + +CollapsibleTrigger.defaultProps = { + children: undefined, + tag: 'div', + openOnly: false, + closeOnly: false, + onClick: undefined, + onKeyDown: undefined, +}; + +export default CollapsibleTrigger; diff --git a/src/Collapsible/CollapsibleVisible.jsx b/src/Collapsible/CollapsibleVisible.jsx new file mode 100644 index 0000000000..e8929a0595 --- /dev/null +++ b/src/Collapsible/CollapsibleVisible.jsx @@ -0,0 +1,32 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; + +import { CollapsibleContext } from './CollapsibleAdvanced'; + +function CollapsibleVisible({ + children, + whenOpen: visibleWhenOpen, + whenClosed: visibleWhenClosed, +}) { + const { isOpen } = useContext(CollapsibleContext); + const isVisible = (isOpen && visibleWhenOpen) || (!isOpen && visibleWhenClosed); + + if (isVisible) { + return {children}; + } + return null; +} + +CollapsibleVisible.propTypes = { + children: PropTypes.node, + whenOpen: PropTypes.bool, + whenClosed: PropTypes.bool, +}; + +CollapsibleVisible.defaultProps = { + children: undefined, + whenOpen: false, + whenClosed: false, +}; + +export default CollapsibleVisible; diff --git a/src/Collapsible/README.md b/src/Collapsible/README.md deleted file mode 100644 index 66273ea167..0000000000 --- a/src/Collapsible/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Collapsible Component - -Provides a component that can collapse to hide its child elements and -optionally has the ability to display without the open/close button based on the -screen size. The collapsible functionality mimics that of an accordion section, -with the exception that multiple collapsibles can be open at the same time. - -Note: The CSS is required for the hide/show functionality and animations to -work properly. - -## API - -### `children` (object; required) -`children` are the objects that are the children of the collapsible that should -be hidden when the collapsible is closed. - -### `title` (string; required) -`title` is the string to be displayed on the collapsible button. - -### `expandedTitle` (element; optional) -`expandedTitle` is the element to be displayed as the title when the collapsible -is expanded. Defaults to undefined. - -### `isOpen` (boolean; optional) -`isOpen` specifies whether the collapsible should initially be open. Defaults -to false. - -### `isCollapsible` (function; optional) -`isCollapsible` is the optional function that, if given, will be used on -resize to determine whether to display the collapsible or regular view. The -example below demonstrates a collapsible that will only show the open/close -button for non-desktop screens. - -If no function is given, the collapsible does not handle resizing and will -always show the open/close button. - -### `iconId` (string; optional) -`iconId` is the id attribute that is passed to the icon on the collapsible. -Defaults to the empty string. - -### `onToggle` (function; optional) -`onToggle` is an optional callback that is trigged when the Collapsible components is opened or closed. A boolean is passed to the callback with the value of `isOpen` from the component's state. \ No newline at end of file diff --git a/src/Collapsible/__snapshots__/Collapsible.test.jsx.snap b/src/Collapsible/__snapshots__/Collapsible.test.jsx.snap new file mode 100644 index 0000000000..f0a624ab3b --- /dev/null +++ b/src/Collapsible/__snapshots__/Collapsible.test.jsx.snap @@ -0,0 +1,208 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Controlled Rendering renders closed by default 1`] = ` +
+
+

+ A heading +

+ + + + +
+
+ Close +
+
+ Open +
+
+
+
+
+`; + +exports[` Controlled Rendering renders open by default 1`] = ` +
+
+

+ A heading +

+ + + + +
+
+ Close +
+
+ Open +
+
+
+
+
+`; + +exports[` Uncontrolled Rendering renders closed by default 1`] = ` +
+
+

+ A heading +

+ + + + +
+
+ Close +
+
+ Open +
+
+
+
+
+`; + +exports[` Uncontrolled Rendering renders open by default 1`] = ` +
+
+

+ A heading +

+ + - + +
+
+ Close +
+
+ Open +
+
+
+

+ Example content +

+
+
+
+`; diff --git a/src/Collapsible/index.jsx b/src/Collapsible/index.jsx index 0b3e014a4d..ff4c586508 100644 --- a/src/Collapsible/index.jsx +++ b/src/Collapsible/index.jsx @@ -1,143 +1,79 @@ import React from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; -import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faPlusCircle, + faMinusCircle, + faPlus, + faMinus, +} from '@fortawesome/free-solid-svg-icons'; + +import CollapsibleAdvanced from './CollapsibleAdvanced'; +import CollapsibleBody from './CollapsibleBody'; +import CollapsibleTrigger from './CollapsibleTrigger'; +import CollapsibleVisible from './CollapsibleVisible'; + +const styleIcons = { + basic: { + iconWhenClosed: , + iconWhenOpen: , + }, + // card and card-lg use the defaults specified in defaultProps +}; -import Button from '../Button'; - -class Collapsible extends React.Component { - constructor(props) { - super(props); - this.state = { - isExpanded: false, - isOpen: props.isOpen, - }; - - this.handleClick = this.handleClick.bind(this); - } - - componentDidMount() { - if (this.props.isCollapsible) { - this.handleResize(); - global.addEventListener('resize', this.handleResize.bind(this)); - } - } - - /** - * "Note that you may call setState() immediately in componentDidUpdate() but, - * it must be wrapped in a conditional check against the previous props, or - * you'll cause an infinite loop." - * See https://reactjs.org/docs/react-component.html#componentdidupdate for - * more information. - */ - componentDidUpdate(prevProps) { - if (this.props.isOpen !== prevProps.isOpen) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - isOpen: this.props.isOpen, - }); - } - } - - componentWillUnmount() { - if (this.props.isCollapsible) { - global.removeEventListener('resize', this.handleResize); - } - } - - handleResize() { - const { isExpanded } = this.state; - - if (isExpanded !== this.props.isCollapsible()) { - this.setState({ - isExpanded: !isExpanded, - }); - } - } - - handleClick() { - const isOpen = !this.state.isOpen; - this.setState({ isOpen }); - this.props.onToggle(isOpen); - } - - renderIcon() { - const { icons } = this.props; - const { isOpen } = this.state; - - if (icons) { - return isOpen ? icons.expanded : icons.collapsed; - } - - return ; - } - - render() { - const { - children, - expandedTitle, - title, - } = this.props; - - const { isExpanded, isOpen } = this.state; - - return ( -
- {isExpanded ? ( - expandedTitle - ) : ( - - )} -
- {children} -
-
- ); - } -} +const Collapsible = React.forwardRef((props, ref) => { + const { + children, + className, + title, + styling, + iconWhenClosed, + iconWhenOpen, + ...other + } = props; + + const icons = Object.assign({ iconWhenClosed, iconWhenOpen }, styleIcons[styling]); + const titleElement = React.isValidElement(title) ? title : {title}; + + return ( + + + {titleElement} + + {icons.iconWhenClosed} + {icons.iconWhenOpen} + + + + {children} + + ); +}); Collapsible.propTypes = { - children: PropTypes.instanceOf(Object).isRequired, - expandedTitle: PropTypes.element, - icons: PropTypes.shape({ - expanded: PropTypes.element.isRequired, - collapsed: PropTypes.element.isRequired, - }), - isCollapsible: PropTypes.func, - isOpen: PropTypes.bool, - onToggle: PropTypes.func, - title: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + className: PropTypes.string, + title: PropTypes.node.isRequired, + styling: PropTypes.oneOf(['basic', 'card', 'card-lg']), + iconWhenClosed: PropTypes.element, + iconWhenOpen: PropTypes.element, }; - Collapsible.defaultProps = { - expandedTitle: undefined, - icons: null, - isCollapsible: undefined, - isOpen: false, - onToggle: () => {}, + className: undefined, + styling: 'card', + iconWhenClosed: , + iconWhenOpen: , + }; +Collapsible.Advanced = CollapsibleAdvanced; +Collapsible.Body = CollapsibleBody; +Collapsible.Trigger = CollapsibleTrigger; +Collapsible.Visible = CollapsibleVisible; + export default Collapsible; diff --git a/src/Dropdown/index.jsx b/src/Dropdown/index.jsx index 68dbfe6ed1..aa6eb0d954 100644 --- a/src/Dropdown/index.jsx +++ b/src/Dropdown/index.jsx @@ -8,7 +8,6 @@ import DropdownItem from './DropdownItem'; import withDeprecatedProps, { DEPR_TYPES } from '../withDeprecatedProps'; const { Provider, Consumer } = React.createContext(); -// const DropdownContext = React.createContext(); class Dropdown extends React.Component { static idCounter = 0; // For creating unique ids diff --git a/src/index.scss b/src/index.scss index 68637b60bb..0049634de6 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,5 +1,5 @@ @import './asInput/asInput.scss'; -@import './Collapsible/Collapsible.scss'; +@import '../scss/core/extensions/collapsible.scss'; @import './Fieldset/Fieldset.scss'; @import './Modal/Modal.scss'; @import './Pagination/Pagination.scss'; diff --git a/www/gatsby-node.js b/www/gatsby-node.js index 3b80e2ec6e..a6b0cc9051 100644 --- a/www/gatsby-node.js +++ b/www/gatsby-node.js @@ -10,6 +10,14 @@ exports.onCreateWebpackConfig = ({ actions }) => { alias: { '~paragon-react': path.resolve(__dirname, '../src'), '~paragon-style': path.resolve(__dirname, '../scss'), + // Prevent multiple copies of react getting loaded + // paragon react components would naturally import + // react and react-dom from the node_modules folder + // one level above if it is present. This approach forces + // all uses of react and react-dom to resolve to those + // in ./node_modules + 'react': path.resolve(__dirname, 'node_modules/react/'), + 'react-dom': path.resolve(__dirname, 'node_modules/react-dom/'), }, }, }); diff --git a/www/src/pages/components/collapsible.mdx b/www/src/pages/components/collapsible.mdx index 58093eccde..faa1c4e957 100644 --- a/www/src/pages/components/collapsible.mdx +++ b/www/src/pages/components/collapsible.mdx @@ -1,78 +1,236 @@ --- title: "Collapsible" type: "component" -status: "Needs Work" -designStatus: "To Do" -devStatus: "To Do" -notes: | - Transition to fully uncontrolled component and add imperative .open(). - Remove use of Button component, it breaks with new styles. - Assess removing the responsive toggle to stay expanded. +status: "Stable" +designStatus: "Needs Review" +devStatus: "Done" +notes: --- import { StaticQuery, graphql } from 'gatsby'; import { Collapsible } from '~paragon-react'; +import { Icons } from '~paragon-react'; import PropsTable from '../../components/PropsTable'; # Collapsible +### Basic Usage -##### basic usage without resizing +The `styling` prop at the top level `` component determines if the collapsible has basic styling, card, or card with heading. + +##### Basic Style `` ```jsx live=true - -

Your stuff goes here

+ +

Your stuff goes here.

``` -##### basic usage with resizing +##### Card Style `` -[TODO] +This is the default style if no `styling` prop is supplied. +```jsx live=true +Toggle Collapsible

} + className="shadow" +> +

Your stuff goes here.

+
+``` -##### initially open collapsible +##### Large Card Style `` ```jsx live=true - -

Your stuff goes here

+Toggle Collapsible} + className="shadow" +> +

Your stuff goes here.

``` - -##### fires onToggle callback when toggled +##### Card with custom icons `` ```jsx live=true console.log(`this.state.isOpen = ${isOpen}`)} // eslint-disable-line no-console + styling="card" + title={

Toggle Collapsible

} + iconWhenOpen={CLOSE SESAME} + iconWhenClosed={OPEN SESAME} + className="shadow" > -

Your stuff goes here

+

Your stuff goes here.

``` +##### Default Open -##### with custom icon +```jsx live=true + +

Your stuff goes here.

+
+``` + +
+ +### Advanced Usage + +For needs that deviate from the three styles above, use `` + +##### Bare minimum ```jsx live=true -, - collapsed: , - }} + + + Toggle Collapsible + + +

Your stuff goes here

+
+
+``` + +##### Card style with advanced usage + +```jsx live=true + + + This is the title + + + - + + + + The content + + +``` + +##### With a close button + +```jsx live=true + + + This is the title + + + - + + + +

The content

+ + + Close + +
+
+``` + + +##### onOpen, onClose and onToggle callbacks + +See the developer console for logging. + +```jsx live=true + console.log('Collapsible toggled and open is: ', isOpen)} + onOpen={() => console.log('Collapsible opened.')} + onClose={() => console.log('Collapsible closed.')} > -

Your stuff goes here

-
+ +
I'm a heading
+ + + + + + + + - + +
+ + +

Your stuff goes here.

+ + + Close + +
+
+``` + +### Controlled usage + +```jsx live=true +function() { + const [collapseIsOpen, setCollapseOpen] = React.useState(true); + + return ( + setCollapseOpen(!isOpen)} + className="collapsible-card" + > + +
I'm a heading
+ + + + + + + + - + +
+ + +

Your stuff goes here.

+ + + Close + +
+
+ ); +} ``` +### Imperative methods + +If you need to open or close the Collapsible intermittently due to an event, +such as loading the page or clicking a link, you can open or close +an **uncontrolled** Collapsible by getting a ref to the component and calling +`collapsibleRef.open()` or `collapsibleRef.close()`. The internal state of +the component will be updated accordingly. + + ##### Props { - if (componentMetadata == null) return null; - return ; + render={({ collapsible, collapsibleTrigger, collapsibleBody, collapsibleVisible }) => { + return ( +
+ {collapsible ? : null} +
Collapsible.Trigger
+ {collapsibleTrigger ? : null} +
Collapsible.Body
+ {collapsibleBody ? : null} +
Collapsible.Visible
+ {collapsibleVisible ? : null} +
+ ) }} /> diff --git a/www/src/scss/_code-block.scss b/www/src/scss/_code-block.scss index 5fa240f31e..cdbc4398a6 100644 --- a/www/src/scss/_code-block.scss +++ b/www/src/scss/_code-block.scss @@ -1,5 +1,5 @@ .pgn-doc__code-block { - margin-bottom: 1rem; + margin-bottom: 4rem; } .pgn-doc__code-block-preview { padding: 1rem 0; diff --git a/www/src/scss/index.scss b/www/src/scss/index.scss index b66671a2fe..32d3dc98f9 100644 --- a/www/src/scss/index.scss +++ b/www/src/scss/index.scss @@ -9,10 +9,16 @@ // Typography .pgn-doc__main-content { - h1, h2, h3, h4, h5 { - margin-top: 2em; + h1 { + margin: 4rem 0; } - + + & > div { + & > h1, & > h2, & > h3, & > h4, & > h5 { + margin-top: 2em; + } + } + // Typography Page .demo-georgia {