diff --git a/CHANGELOG.md b/CHANGELOG.md index e44b8710ac8..c1207c3aca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ - Added new `euiTreeView` component for rendering recursive objects such as folder structures. ([#2409](https://github.com/elastic/eui/pull/2409)) - Added `euiXScrollWithShadows()` mixin and `.eui-xScrollWithShadows` utility class ([#2458](https://github.com/elastic/eui/pull/2458)) - Fixed `EuiColorStops` where empty string values would cause range min or max to be NaN ([#2496](https://github.com/elastic/eui/pull/2496)) +- Improved `EuiSwitch` a11y by aligning to aria roles ([#2491](https://github.com/elastic/eui/pull/2491)) +- Converted `EuiSwitch` to TypeScript ([#2491](https://github.com/elastic/eui/pull/2491)) +- Added an accessible label-less `EuiSwitch` variation ([#2491](https://github.com/elastic/eui/pull/2491)) **Bug fixes** @@ -11,6 +14,7 @@ - Fixed position of `EuiCodeBlock` controls and added more tests ([#2459](https://github.com/elastic/eui/pull/2459)) - Changed `EuiCodeBlock` so that `overflowHeight` now applies a `maxHeight` instead of a `height` on the block ([#2487](https://github.com/elastic/eui/pull/2487)) - Fixed potentially inconsistent state update ([#2481](https://github.com/elastic/eui/pull/2481)) +- Fixed `EuiSwitch` form behavior by adding a default button `type` of 'button' ([#2491](https://github.com/elastic/eui/pull/2491)) ## [`14.8.0`](https://github.com/elastic/eui/tree/v14.8.0) diff --git a/src-docs/src/views/context_menu/context_menu.js b/src-docs/src/views/context_menu/context_menu.js index 1b724641ca3..531ec2e7449 100644 --- a/src-docs/src/views/context_menu/context_menu.js +++ b/src-docs/src/views/context_menu/context_menu.js @@ -79,6 +79,8 @@ export default class extends Component { name="switch" id="asdf" label="Snapshot data" + checked={true} + onChange={() => {}} /> @@ -86,6 +88,8 @@ export default class extends Component { name="switch" id="asdf2" label="Current time range" + checked={true} + onChange={() => {}} /> diff --git a/src-docs/src/views/date_picker/super_date_picker.js b/src-docs/src/views/date_picker/super_date_picker.js index d92324ef9c6..7710d32bb2c 100644 --- a/src-docs/src/views/date_picker/super_date_picker.js +++ b/src-docs/src/views/date_picker/super_date_picker.js @@ -22,7 +22,7 @@ function MyCustomQuickSelectPanel({ applyTime }) { export default class extends Component { state = { recentlyUsedRanges: [], - isDiasabled: false, + isDisabled: false, isLoading: false, showUpdateButton: true, isAutoRefreshOnly: false, diff --git a/src-docs/src/views/form_compressed/form_horizontal.js b/src-docs/src/views/form_compressed/form_horizontal.js index a69d5931ac0..8e28f4ddd63 100644 --- a/src-docs/src/views/form_compressed/form_horizontal.js +++ b/src-docs/src/views/form_compressed/form_horizontal.js @@ -91,6 +91,8 @@ export default class extends Component { + + + + + + + + ); } diff --git a/src-docs/src/views/popover/trap_focus.js b/src-docs/src/views/popover/trap_focus.js index 7d69f04ceb5..d8dde77ae6c 100644 --- a/src-docs/src/views/popover/trap_focus.js +++ b/src-docs/src/views/popover/trap_focus.js @@ -48,11 +48,21 @@ export default class extends Component { closePopover={this.closePopover.bind(this)} initialFocus="[id=asdf2]"> - + {}} + /> - + {}} + /> diff --git a/src-docs/src/views/tables/paginated/paginated.js b/src-docs/src/views/tables/paginated/paginated.js index a2d8fa76ec2..d658a6c3714 100644 --- a/src-docs/src/views/tables/paginated/paginated.js +++ b/src-docs/src/views/tables/paginated/paginated.js @@ -154,6 +154,7 @@ export class Table extends Component { return (
Hide per page options with{' '} diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx index dfcff2741f7..1ba89676cac 100644 --- a/src/components/datagrid/data_grid.test.tsx +++ b/src/components/datagrid/data_grid.test.tsx @@ -311,9 +311,9 @@ function setColumnVisibility( const portal = popover.find('EuiPortal'); const columnSwitch = portal.find(`EuiSwitch[name="${columnId}"]`); - const switchInput = columnSwitch.find('input'); - (switchInput.getDOMNode() as HTMLInputElement).checked = isVisible; - switchInput.simulate('change'); + const switchInput = columnSwitch.find('button'); + switchInput.getDOMNode().setAttribute('aria-checked', `${isVisible}`); + switchInput.simulate('click'); // close popover popover = datagrid.find( diff --git a/src/components/form/_variables.scss b/src/components/form/_variables.scss index 6f2a7728650..9381b8cffc8 100644 --- a/src/components/form/_variables.scss +++ b/src/components/form/_variables.scss @@ -35,3 +35,4 @@ $euiFormControlDisabledColor: $euiColorMediumShade !default; $euiFormControlBoxShadow: 0 1px 1px -1px transparentize($euiShadowColor, .8), 0 3px 2px -2px transparentize($euiShadowColor, .8) !default; $euiFormInputGroupLabelBackground: tintOrShade($euiColorLightShade, 65%, 40%) !default; $euiFormInputGroupBorder: 1px solid shadeOrTint($euiFormInputGroupLabelBackground, 6%, 8%) !default; +$euiSwitchOffColor: lightOrDarkTheme(transparentize($euiColorMediumShade, .8), transparentize($euiColorMediumShade, .3)) !default; diff --git a/src/components/form/index.d.ts b/src/components/form/index.d.ts index fc5c4e83c43..a707dbfd399 100644 --- a/src/components/form/index.d.ts +++ b/src/components/form/index.d.ts @@ -8,7 +8,6 @@ import { CommonProps } from '../common'; /// /// /// -/// /// import { FunctionComponent, FormHTMLAttributes, ReactNode } from 'react'; diff --git a/src/components/form/switch/__snapshots__/switch.test.js.snap b/src/components/form/switch/__snapshots__/switch.test.js.snap deleted file mode 100644 index 7d5d582e468..00000000000 --- a/src/components/form/switch/__snapshots__/switch.test.js.snap +++ /dev/null @@ -1,81 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EuiSwitch assigns automatically generated ID to label 1`] = ` -
- - - - - - - - -
-`; - -exports[`EuiSwitch is rendered 1`] = ` -
- - - - - - - - -
-`; diff --git a/src/components/form/switch/__snapshots__/switch.test.tsx.snap b/src/components/form/switch/__snapshots__/switch.test.tsx.snap new file mode 100644 index 00000000000..a6eecef15c4 --- /dev/null +++ b/src/components/form/switch/__snapshots__/switch.test.tsx.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiSwitch assigns automatically generated ID to label 1`] = ` +
+ +

+ Label +

+
+`; + +exports[`EuiSwitch is rendered 1`] = ` +
+ +

+ Label +

+
+`; diff --git a/src/components/form/switch/_switch.scss b/src/components/form/switch/_switch.scss index be08786cf0f..800938ae35b 100644 --- a/src/components/form/switch/_switch.scss +++ b/src/components/form/switch/_switch.scss @@ -4,30 +4,64 @@ min-height: $euiSwitchHeight; .euiSwitch__label { + cursor: pointer; padding-left: $euiSizeS; line-height: $euiSwitchHeight; font-size: $euiFontSizeS; vertical-align: middle; + display: inline-block; } - /** - * 1. The input is "hidden" but still focusable. - * 2. Make sure it's still hidden when [disabled]. - */ - .euiSwitch__input, - .euiSwitch__input[disabled] /* 2 */ { - position: absolute; - opacity: 0; /* 1 */ - width: 100%; - height: 100%; - cursor: pointer; - } - - .euiSwitch__input:focus + .euiSwitch__body { + .euiSwitch__button { + line-height: 0; // ensures button takes height of switch inside - .euiSwitch__thumb { + &:focus .euiSwitch__thumb { @include euiCustomControlFocused; } + + &:disabled { + &:hover, + ~ .euiSwitch__label:hover { + cursor: not-allowed; + } + + .euiSwitch__body { + background-color: $euiSwitchOffColor; + } + + .euiSwitch__thumb { + @include euiCustomControlDisabled; + background-color: $euiSwitchOffColor; + } + + .euiSwitch__icon { + fill: $euiFormCustomControlDisabledIconColor; + } + + + .euiSwitch__label { + color: $euiFormControlDisabledColor; + } + } + + &[aria-checked='false'] { + .euiSwitch__body { + background-color: $euiSwitchOffColor; + } + + // When input is not checked, we shift around the positioning of the thumb and the icon + .euiSwitch__thumb { // move the thumb left + left: 0; + } + + .euiSwitch__icon { // move the icon right + right: -$euiSizeS; + + &.euiSwitch__icon--checked { + right: auto; + left: -($euiSwitchWidth - ($euiSwitchThumbSize / 2)); + } + } + } } .euiSwitch__body { @@ -77,74 +111,16 @@ fill: $euiColorEmptyShade; } - /** - * The thumb is slightly scaled when in use, unless it's disabled. - */ - &:hover { - .euiSwitch__input:not(:disabled) ~ .euiSwitch__body { - .euiSwitch__thumb { - transform: scale(1.05); - } + &:hover .euiSwitch__button { + &:not(:disabled) .euiSwitch__thumb { + transform: scale(1.05); } - } - &:active { - .euiSwitch__thumb { + &:active .euiSwitch__thumb { transform: scale(.95); } } - .euiSwitch__input:disabled:hover { - cursor: not-allowed; - } - - .euiSwitch__input:disabled ~ .euiSwitch__body, - .euiSwitch__input:checked:disabled ~ .euiSwitch__body { - background-color: lightOrDarkTheme(transparentize($euiColorMediumShade, .8), transparentize($euiColorMediumShade, .3)); - - .euiSwitch__thumb { - @include euiCustomControlDisabled; - - border-color: $euiFormBorderColor; - background-color: lightOrDarkTheme(transparentize($euiColorMediumShade, .8), transparentize($euiColorMediumShade, .3)); - } - - .euiSwitch__icon { - fill: $euiFormCustomControlDisabledIconColor; - } - - + label { - color: $euiFormControlDisabledColor; - } - } - - .euiSwitch__input:checked:disabled ~ .euiSwitch__body { - background-color: lightOrDarkTheme(transparentize($euiColorMediumShade, .7), transparentize($euiColorMediumShade, .4)); - } - - // Slightly darker background when in a checked state. - .euiSwitch__input:not(:checked):not(:disabled) ~ .euiSwitch__body { - background-color: lightOrDarkTheme(transparentize($euiColorMediumShade, .8), transparentize($euiColorMediumShade, .3)); - } - - /** - * When input is not checked, we shift around the positioning of sibling/child selectors. - */ - .euiSwitch__input:not(:checked) ~ .euiSwitch__body { - .euiSwitch__thumb { - left: 0; - } - - .euiSwitch__icon { - right: -$euiSizeS; - - &.euiSwitch__icon--checked { - right: auto; - left: -($euiSwitchWidth - ($euiSwitchThumbSize / 2)); - } - } - } - // Compressed switches operate very similar to the normal versions, but are smaller, contain no icon signifiers &.euiSwitch--compressed { min-height: $euiSwitchHeightCompressed; @@ -170,28 +146,6 @@ .euiSwitch__track { border-radius: $euiSwitchHeightCompressed; } - - .euiSwitch__input:not(:checked) ~ .euiSwitch__body { - .euiSwitch__thumb { - left: 1px; - } - } - - // Compressed switches need slightly darker borders since they don't have icons - .euiSwitch__input:not(:checked) ~ .euiSwitch__body, - .euiSwitch__input:checked:disabled ~ .euiSwitch__body { - .euiSwitch__thumb { - border-color: $euiFormCustomControlBorderColor; - } - } - - // Similar additional treatment needed while checked - .euiSwitch__input:checked ~ .euiSwitch__body { - .euiSwitch__thumb { - border-color: $euiColorPrimary; - } - } - } // Mini styling is similar to compressed, but even smaller. It's undocumented because it has very specific uses. @@ -220,23 +174,28 @@ .euiSwitch__track { border-radius: $euiSwitchHeightMini; } + } + + // Compressed and mini switches have some style overlap + &.euiSwitch--compressed, + &.euiSwitch--mini { - .euiSwitch__input:not(:checked) ~ .euiSwitch__body { + .euiSwitch__button[aria-checked='false'] { .euiSwitch__thumb { left: 1px; } } - // Compressed switches need slightly darker borders since they don't have icons - .euiSwitch__input:not(:checked) ~ .euiSwitch__body, - .euiSwitch__input:checked:disabled ~ .euiSwitch__body { + // Compressed and mini switches need slightly darker borders since they don't have icons + .euiSwitch__button[aria-checked='false'], + .euiSwitch__button[aria-checked='true']:disabled { .euiSwitch__thumb { border-color: $euiFormCustomControlBorderColor; } } // Similar additional treatment needed while checked - .euiSwitch__input:checked ~ .euiSwitch__body { + .euiSwitch__button[aria-checked='true'] { .euiSwitch__thumb { border-color: $euiColorPrimary; } diff --git a/src/components/form/switch/index.d.ts b/src/components/form/switch/index.d.ts deleted file mode 100644 index 3afc67edcb7..00000000000 --- a/src/components/form/switch/index.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CommonProps } from '../../common'; - -import { FunctionComponent, InputHTMLAttributes, ReactNode } from 'react'; - -declare module '@elastic/eui' { - /** - * @see './switch.js' - */ - export type EuiSwitchProps = CommonProps & - InputHTMLAttributes & { - label?: ReactNode; - compressed?: boolean; - }; - - export const EuiSwitch: FunctionComponent; -} diff --git a/src/components/form/switch/index.js b/src/components/form/switch/index.js deleted file mode 100644 index 893cd7f7f6c..00000000000 --- a/src/components/form/switch/index.js +++ /dev/null @@ -1 +0,0 @@ -export { EuiSwitch } from './switch'; diff --git a/src/components/form/switch/index.ts b/src/components/form/switch/index.ts new file mode 100644 index 00000000000..6c4ce9a8635 --- /dev/null +++ b/src/components/form/switch/index.ts @@ -0,0 +1 @@ +export { EuiSwitch, EuiSwitchEvent, EuiSwitchProps } from './switch'; diff --git a/src/components/form/switch/switch.js b/src/components/form/switch/switch.js deleted file mode 100644 index 06c490887e9..00000000000 --- a/src/components/form/switch/switch.js +++ /dev/null @@ -1,89 +0,0 @@ -import React, { Component, Fragment } from 'react'; - -import PropTypes from 'prop-types'; -import classNames from 'classnames'; - -import makeId from '../../form/form_row/make_id'; -import { EuiIcon } from '../../icon'; - -export class EuiSwitch extends Component { - constructor(props) { - super(props); - - this.state = { - switchId: props.id || makeId(), - }; - } - - render() { - const { - label, - id, - name, - checked, - disabled, - compressed, - onChange, - className, - ...rest - } = this.props; - - const { switchId } = this.state; - - const classes = classNames( - 'euiSwitch', - { - 'euiSwitch--compressed': compressed, - }, - className - ); - - return ( -
- - - - - - {!compressed && ( - - - - - - )} - - - - {label && ( - - )} -
- ); - } -} - -EuiSwitch.propTypes = { - name: PropTypes.string, - id: PropTypes.string, - label: PropTypes.node, - checked: PropTypes.bool, - onChange: PropTypes.func, - disabled: PropTypes.bool, - compressed: PropTypes.bool, -}; diff --git a/src/components/form/switch/switch.test.js b/src/components/form/switch/switch.test.tsx similarity index 65% rename from src/components/form/switch/switch.test.js rename to src/components/form/switch/switch.test.tsx index 50927c7fa6e..a564df98ca1 100644 --- a/src/components/form/switch/switch.test.js +++ b/src/components/form/switch/switch.test.tsx @@ -4,17 +4,25 @@ import { requiredProps } from '../../../test/required_props'; import { EuiSwitch } from './switch'; +const props = { + checked: false, + label: 'Label', + onChange: () => {}, +}; + jest.mock('../form_row/make_id', () => () => 'generated-id'); describe('EuiSwitch', () => { test('is rendered', () => { - const component = render(); + const component = render( + + ); expect(component).toMatchSnapshot(); }); test('assigns automatically generated ID to label', () => { - const component = render(); + const component = render(); expect(component).toMatchSnapshot(); }); diff --git a/src/components/form/switch/switch.tsx b/src/components/form/switch/switch.tsx new file mode 100644 index 00000000000..f1ccbb71d8e --- /dev/null +++ b/src/components/form/switch/switch.tsx @@ -0,0 +1,116 @@ +import React, { + ButtonHTMLAttributes, + FunctionComponent, + ReactNode, + useState, +} from 'react'; +import classNames from 'classnames'; + +import { CommonProps, Omit } from '../../common'; +import makeId from '../../form/form_row/make_id'; +import { EuiIcon } from '../../icon'; + +export type EuiSwitchEvent = React.BaseSyntheticEvent< + React.MouseEvent, + HTMLButtonElement, + EventTarget & { + checked: boolean; + } +>; + +export type EuiSwitchProps = CommonProps & + Omit, 'onChange'> & { + /** + * Whether to render the render the text label + */ + showLabel?: boolean; + /** + * Must be a string if `showLabel` prop is false + */ + label: ReactNode | string; + checked: boolean; + onChange: (event: EuiSwitchEvent) => void; + disabled?: boolean; + compressed?: boolean; + }; + +export const EuiSwitch: FunctionComponent = ({ + label, + id, + name, + checked, + disabled, + compressed, + onChange, + className, + showLabel = true, + type = 'button', + ...rest +}) => { + const [switchId] = useState(id || makeId()); + const [labelId] = useState(makeId()); + + const onClick = ( + e: React.MouseEvent + ) => { + const event = (e as unknown) as EuiSwitchEvent; + event.target.checked = !checked; + onChange(event); + }; + + const classes = classNames( + 'euiSwitch', + { + 'euiSwitch--compressed': compressed, + }, + className + ); + + if (showLabel === false && typeof label !== 'string') { + console.warn( + 'EuiSwitch `label` must be a string when `showLabel` is false.' + ); + } + + return ( +
+ + + {showLabel && ( + //
+ ); +}; diff --git a/src/global_styling/reset/_reset.scss b/src/global_styling/reset/_reset.scss index 20ca7ec3eab..21ba3d5e410 100644 --- a/src/global_styling/reset/_reset.scss +++ b/src/global_styling/reset/_reset.scss @@ -146,4 +146,4 @@ hr { fieldset { min-inline-size: auto; -} \ No newline at end of file +}