Skip to content

Commit

Permalink
Components: Add Disabled component
Browse files Browse the repository at this point in the history
  • Loading branch information
aduth committed Feb 23, 2018
1 parent 01ea7d8 commit dde1787
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 0 deletions.
33 changes: 33 additions & 0 deletions components/disabled/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
Disabled
========

Disabled is a component which disables descendant tabbable elements and prevents pointer interaction.

## Usage

Assuming you have a form component, you can disable all form inputs by wrapping the form with `<Disabled>`.

```jsx
const DisableToggleForm = withState( {
isDisabled: true,
} )( ( { isDisabled, setState } ) => {
let form = <form><input /></form>;

if ( isDisabled ) {
form = <Disabled>{ form }</Disabled>;
}

const toggleDisabled = setState( ( state ) => ( {
isDisabled: ! state.isDisabled,
} ) );

return (
<div>
{ form }
<button onClick={ toggleDisabled }>
Toggle Disabled
</button>
</div>
);
} )
```
70 changes: 70 additions & 0 deletions components/disabled/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* External dependencies
*/
import { debounce } from 'lodash';

/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
import { focus } from '@wordpress/utils';

/**
* Internal dependencies
*/
import './style.scss';

class Disabled extends Component {
constructor() {
super( ...arguments );

this.bindNode = this.bindNode.bind( this );
this.disable = this.disable.bind( this );

// Disable re-disable since disabling process itself will incur
// additional mutations which should be ignored.
this.debouncedDisable = debounce( this.disable, { leading: true } );
}

componentDidMount() {
this.disable();

this.observer = new window.MutationObserver( this.debouncedDisable );
this.observer.observe( this.node, {
childList: true,
attributes: true,
subtree: true,
} );
}

componentWillUnmount() {
this.observer.disconnect();
this.debouncedDisable.cancel();
}

bindNode( node ) {
this.node = node;
}

disable() {
focus.focusable.find( this.node ).forEach( ( focusable ) => {
if ( ! focusable.hasAttribute( 'disabled' ) ) {
focusable.setAttribute( 'disabled', '' );
}

if ( focusable.hasAttribute( 'contenteditable' ) ) {
focusable.setAttribute( 'contenteditable', 'false' );
}
} );
}

render() {
return (
<div ref={ this.bindNode } className="components-disabled">
{ this.props.children }
</div>
);
}
}

export default Disabled;
12 changes: 12 additions & 0 deletions components/disabled/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.components-disabled {
position: relative;

&:after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
}
88 changes: 88 additions & 0 deletions components/disabled/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { mount } from 'enzyme';

/**
* Internal dependencies
*/
import Disabled from '../';

jest.mock( '@wordpress/utils', () => {
const focus = require.requireActual( '@wordpress/utils' ).focus;

return {
focus: {
...focus,
focusable: {
...focus.focusable,
find( context ) {
// In JSDOM, all elements have zero'd widths and height.
// This is a metric for focusable's `isVisible`, so find
// and apply an arbitrary non-zero width.
[ ...context.querySelectorAll( '*' ) ].forEach( ( element ) => {
Object.defineProperties( element, {
offsetWidth: {
get: () => 1,
},
} );
} );

return focus.focusable.find( ...arguments );
},
},
},
};
} );

describe( 'Disabled', () => {
let MutationObserver;

beforeAll( () => {
MutationObserver = window.MutationObserver;
window.MutationObserver = function() {};
window.MutationObserver.prototype = {
observe() {},
disconnect() {},
};
} );

afterAll( () => {
window.MutationObserver = MutationObserver;
} );

const Form = () => <form><input /><div contentEditable /></form>;

it( 'will disable all fields', () => {
const wrapper = mount( <Disabled><Form /></Disabled> );

expect( wrapper.find( 'input' ).getDOMNode().hasAttribute( 'disabled' ) ).toBe( true );
expect( wrapper.find( '[contentEditable]' ).getDOMNode().getAttribute( 'contenteditable' ) ).toBe( 'false' );
} );

it( 'should cleanly un-disable via reconciliation', () => {
// If this test suddenly starts failing, it means React has become
// smarter about reusing children into grandfather element when the
// parent is dropped, so we'd need to find another way to restore
// original form state.
function MaybeDisable( { isDisabled = true } ) {
const element = <Form />;
return isDisabled ? <Disabled>{ element }</Disabled> : element;
}

const wrapper = mount( <MaybeDisable /> );
wrapper.setProps( { isDisabled: false } );

expect( wrapper.find( 'input' ).getDOMNode().hasAttribute( 'disabled' ) ).toBe( false );
expect( wrapper.find( '[contentEditable]' ).getDOMNode().getAttribute( 'contenteditable' ) ).toBe( 'true' );
} );

// Ideally, we'd have two more test cases here:
//
// - it( 'will disable all fields on component render change' )
// - it( 'will disable all fields on sneaky DOM manipulation' )
//
// Alas, JSDOM does not support MutationObserver:
//
// https://github.com/jsdom/jsdom/issues/639
} );
1 change: 1 addition & 0 deletions components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { default as CheckboxControl } from './checkbox-control';
export { default as ClipboardButton } from './clipboard-button';
export { default as Dashicon } from './dashicon';
export { DateTimePicker, DatePicker, TimePicker } from './date-time';
export { default as Disabled } from './disabled';
export { default as DropZone } from './drop-zone';
export { default as DropZoneProvider } from './drop-zone/provider';
export { default as Dropdown } from './dropdown';
Expand Down

0 comments on commit dde1787

Please sign in to comment.