diff --git a/src-docs/src/views/list_group/list_group_example.js b/src-docs/src/views/list_group/list_group_example.js index d4642cb9066..bf2aa4cd593 100644 --- a/src-docs/src/views/list_group/list_group_example.js +++ b/src-docs/src/views/list_group/list_group_example.js @@ -1,4 +1,5 @@ import React from 'react'; +import { Link } from 'react-router'; import { renderToHtml } from '../../services'; @@ -7,8 +8,10 @@ import { GuideSectionTypes } from '../../components'; import { EuiListGroup, EuiListGroupItem, + EuiPinnableListGroup, EuiCode, } from '../../../../src/components'; +import { EuiPinnableListGroupItem } from './props'; import ListGroup from './list_group'; const listGroupSource = require('!!raw-loader!./list_group'); @@ -30,6 +33,10 @@ import ListGroupItemColor from './list_group_item_color'; const listGroupItemColorSource = require('!!raw-loader!./list_group_item_color'); const listGroupItemColorHtml = renderToHtml(ListGroupItemColor); +import PinnableListGroup from './pinnable_list_group'; +const pinnableListGroupSource = require('!!raw-loader!./pinnable_list_group'); +const pinnableListGroupHtml = renderToHtml(PinnableListGroup); + export const ListGroupExample = { title: 'List group', sections: [ @@ -205,6 +212,58 @@ export const ListGroupExample = { label="Primary" color="primary" size="s" +/>`, + }, + { + title: 'Pinnable list group', + source: [ + { + type: GuideSectionTypes.JS, + code: pinnableListGroupSource, + }, + { + type: GuideSectionTypes.HTML, + code: pinnableListGroupHtml, + }, + ], + text: ( + <> +

+ EuiPinnableListGroup is simply an extra wrapper + around an{' '} + + EuiListGroup + {' '} + that provides visual indicators for pinning. +

+

+ Pinning is the concept that users can click a pin icon and add it to + a subset of links (most likely shown in different list group). By + providing an onPinClick handler, the component + will automatically add the pin action to the item. However, the + consuming application must manage the listItems + and their pinned state. +

+

+ In order to get the full benefit of using{' '} + EuiPinnableListGroup, the component only supports + providing list items via the listItem prop and + does not support children. +

+ + ), + props: { EuiPinnableListGroup, EuiPinnableListGroupItem }, + demo: , + snippet: ` {}} + listItems={[ + { + label: 'A link', + href: '#', + pinned: true, + isActive: true, + }, + ]} />`, }, ], diff --git a/src-docs/src/views/list_group/list_group_link_actions.js b/src-docs/src/views/list_group/list_group_link_actions.js index e3e31b1df89..ec6edf64304 100644 --- a/src-docs/src/views/list_group/list_group_link_actions.js +++ b/src-docs/src/views/list_group/list_group_link_actions.js @@ -68,7 +68,7 @@ export default class extends Component { extraAction={{ color: 'subdued', onClick: this.link1Clicked, - iconType: favorite1 === 'link1' ? 'pinFilled' : 'pin', + iconType: favorite1 === 'link1' ? 'starFilled' : 'starEmpty', iconSize: 's', 'aria-label': 'Favorite link1', alwaysShow: favorite1 === 'link1', @@ -83,7 +83,7 @@ export default class extends Component { extraAction={{ color: 'subdued', onClick: this.link2Clicked, - iconType: favorite2 === 'link2' ? 'pinFilled' : 'pin', + iconType: favorite2 === 'link2' ? 'starFilled' : 'starEmpty', iconSize: 's', 'aria-label': 'Favorite link2', alwaysShow: favorite2 === 'link2', @@ -98,7 +98,7 @@ export default class extends Component { extraAction={{ color: 'subdued', onClick: this.link3Clicked, - iconType: favorite3 === 'link3' ? 'pinFilled' : 'pin', + iconType: favorite3 === 'link3' ? 'starFilled' : 'starEmpty', iconSize: 's', 'aria-label': 'Favorite link3', alwaysShow: favorite3 === 'link3', @@ -114,7 +114,7 @@ export default class extends Component { extraAction={{ color: 'subdued', onClick: () => window.alert('Action clicked'), - iconType: 'pin', + iconType: 'starEmpty', iconSize: 's', 'aria-label': 'Favorite link4', }} diff --git a/src-docs/src/views/list_group/pinnable_list_group.tsx b/src-docs/src/views/list_group/pinnable_list_group.tsx new file mode 100644 index 00000000000..8f0ba4f7564 --- /dev/null +++ b/src-docs/src/views/list_group/pinnable_list_group.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { + EuiPinnableListGroup, + EuiPinnableListGroupItemProps, +} from '../../../../src/components/list_group'; + +const someListItems: EuiPinnableListGroupItemProps[] = [ + { + label: 'Label with iconType', + iconType: 'stop', + }, + { + label: 'Pinned button with onClick', + pinned: true, + onClick: e => { + console.log('Pinned button clicked', e); + }, + }, + { + label: 'Link with href and custom pin titles', + href: '/#', + }, + { + label: 'Active link', + isActive: true, + href: '/#', + }, + { + label: 'Custom extra actions will override pinning ability', + extraAction: { + iconType: 'bell', + alwaysShow: true, + 'aria-label': 'bell', + }, + }, +]; + +export default () => ( + <> + { + console.warn('Clicked: ', item); + }} + maxWidth="none" + pinTitle={(item: EuiPinnableListGroupItemProps) => `Pin ${item.label}`} + unpinTitle={(item: EuiPinnableListGroupItemProps) => + `Unpin ${item.label}` + } + /> + +); diff --git a/src-docs/src/views/list_group/props.tsx b/src-docs/src/views/list_group/props.tsx new file mode 100644 index 00000000000..c131a02172c --- /dev/null +++ b/src-docs/src/views/list_group/props.tsx @@ -0,0 +1,7 @@ +import React, { FunctionComponent } from 'react'; + +import { EuiPinnableListGroupItemProps } from '../../../../src/components/list_group'; + +export const EuiPinnableListGroupItem: FunctionComponent< + EuiPinnableListGroupItemProps +> = () =>
; diff --git a/src/components/collapsible_nav/_index.scss b/src/components/collapsible_nav/_index.scss index fa7acfa01df..a31c44be949 100644 --- a/src/components/collapsible_nav/_index.scss +++ b/src/components/collapsible_nav/_index.scss @@ -1,4 +1,4 @@ @import 'variables'; -@import 'collapsible_nav_group/index'; @import 'collapsible_nav'; +@import 'collapsible_nav_group/index'; diff --git a/src/components/index.js b/src/components/index.js index 8846bf27027..07d30878e66 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -186,7 +186,11 @@ export { export { EuiLink } from './link'; -export { EuiListGroup, EuiListGroupItem } from './list_group'; +export { + EuiListGroup, + EuiListGroupItem, + EuiPinnableListGroup, +} from './list_group'; export { EUI_MODAL_CANCEL_BUTTON, diff --git a/src/components/list_group/__snapshots__/list_group.test.tsx.snap b/src/components/list_group/__snapshots__/list_group.test.tsx.snap index c8a941b009b..860d689dbe8 100644 --- a/src/components/list_group/__snapshots__/list_group.test.tsx.snap +++ b/src/components/list_group/__snapshots__/list_group.test.tsx.snap @@ -44,6 +44,7 @@ exports[`EuiListGroup is rendered with listItems 1`] = ` +
  • + + + Active link + + +
  • @@ -123,6 +139,7 @@ exports[`EuiListGroup is rendered with listItems and color 1`] = `
  • +
  • + + + Active link + + +
  • diff --git a/src/components/list_group/_index.scss b/src/components/list_group/_index.scss index bf49f315ffe..d8aaf029726 100644 --- a/src/components/list_group/_index.scss +++ b/src/components/list_group/_index.scss @@ -2,3 +2,4 @@ @import 'list_group'; @import 'list_group_item'; +@import 'pinnable_list_group/index'; diff --git a/src/components/list_group/_list_group_item.scss b/src/components/list_group/_list_group_item.scss index ebade8154aa..8432f004754 100644 --- a/src/components/list_group/_list_group_item.scss +++ b/src/components/list_group/_list_group_item.scss @@ -13,19 +13,28 @@ background-color: $euiListGroupItemHoverBackground; } - &.euiListGroupItem-isClickable .euiListGroupItem__button:focus { + // Can't be grouped with above or else IE will ignore the whole group + &.euiListGroupItem-isClickable:focus-within { background-color: $euiListGroupItemHoverBackground; - text-decoration: underline; } &.euiListGroupItem--ghost { - &.euiListGroupItem-isActive, - &.euiListGroupItem-isClickable:hover, - &.euiListGroupItem-isClickable .euiListGroupItem__button:focus { + &.euiListGroupItem-isClickable:hover { + background-color: $euiListGroupItemHoverBackgroundGhost; + } + + // Can't be grouped with above or else IE will ignore the whole group + &.euiListGroupItem-isClickable:focus-within { background-color: $euiListGroupItemHoverBackgroundGhost; } } + &.euiListGroupItem-isClickable:hover .euiListGroupItem__button, + .euiListGroupItem__button:hover, + .euiListGroupItem__button:focus { + text-decoration: underline; + } + // Style all disabled list items whether or not they are links or buttons &.euiListGroupItem-isDisabled, &.euiListGroupItem-isDisabled:hover, @@ -33,9 +42,23 @@ &.euiListGroupItem-isDisabled .euiListGroupItem__button:hover, &.euiListGroupItem-isDisabled .euiListGroupItem__button:focus { color: $euiButtonColorDisabled; - text-decoration: none; cursor: not-allowed; background-color: transparent; + text-decoration: none; + } +} + +// IE doesn't support :focus-within +@include internetExplorerOnly { + .euiListGroupItem__button:hover, + .euiListGroupItem__button:focus { + background-color: $euiListGroupItemHoverBackground; + border-radius: $euiBorderRadius; + + .euiListGroupItem--ghost .euiListGroupItem__button:hover, + .euiListGroupItem--ghost .euiListGroupItem__button:focus { + background-color: $euiListGroupItemHoverBackgroundGhost; + } } } diff --git a/src/components/list_group/index.ts b/src/components/list_group/index.ts index a82a234ef34..51a48e94092 100644 --- a/src/components/list_group/index.ts +++ b/src/components/list_group/index.ts @@ -1,2 +1,7 @@ export { EuiListGroup, EuiListGroupProps } from './list_group'; export { EuiListGroupItem, EuiListGroupItemProps } from './list_group_item'; +export { + EuiPinnableListGroup, + EuiPinnableListGroupProps, + EuiPinnableListGroupItemProps, +} from './pinnable_list_group'; diff --git a/src/components/list_group/list_group.test.tsx b/src/components/list_group/list_group.test.tsx index 52a7002b4b9..5bc6fae9644 100644 --- a/src/components/list_group/list_group.test.tsx +++ b/src/components/list_group/list_group.test.tsx @@ -15,6 +15,7 @@ const someListItems: EuiListGroupItemProps[] = [ extraAction: { iconType: 'bell', alwaysShow: true, + 'aria-label': 'bell', }, }, { @@ -23,6 +24,11 @@ const someListItems: EuiListGroupItemProps[] = [ console.log('Visualize clicked', e); }, }, + { + label: 'Active link', + isActive: true, + href: '#', + }, { label: 'Link with href', href: '#', diff --git a/src/components/list_group/pinnable_list_group/__snapshots__/pinnable_list_group.test.tsx.snap b/src/components/list_group/pinnable_list_group/__snapshots__/pinnable_list_group.test.tsx.snap new file mode 100644 index 00000000000..52b5080d5ce --- /dev/null +++ b/src/components/list_group/pinnable_list_group/__snapshots__/pinnable_list_group.test.tsx.snap @@ -0,0 +1,291 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiPinnableListGroup can have custom pin icon titles 1`] = ` + +`; + +exports[`EuiPinnableListGroup is rendered 1`] = ` + +`; diff --git a/src/components/list_group/pinnable_list_group/_index.scss b/src/components/list_group/pinnable_list_group/_index.scss new file mode 100644 index 00000000000..0bd00f0595e --- /dev/null +++ b/src/components/list_group/pinnable_list_group/_index.scss @@ -0,0 +1 @@ +@import 'pinnable_list_group'; diff --git a/src/components/list_group/pinnable_list_group/_pinnable_list_group.scss b/src/components/list_group/pinnable_list_group/_pinnable_list_group.scss new file mode 100644 index 00000000000..05f7191ad2a --- /dev/null +++ b/src/components/list_group/pinnable_list_group/_pinnable_list_group.scss @@ -0,0 +1,9 @@ +.euiPinnableListGroup__itemExtraAction { + svg { + transform: rotate(45deg); + } +} + +.euiPinnableListGroup__itemExtraAction-pinned:not(:hover):not(:focus) { + color: makeGraphicContrastColor($euiColorLightShade); +} diff --git a/src/components/list_group/pinnable_list_group/index.ts b/src/components/list_group/pinnable_list_group/index.ts new file mode 100644 index 00000000000..0b9cf97d764 --- /dev/null +++ b/src/components/list_group/pinnable_list_group/index.ts @@ -0,0 +1,5 @@ +export { + EuiPinnableListGroup, + EuiPinnableListGroupProps, + EuiPinnableListGroupItemProps, +} from './pinnable_list_group'; diff --git a/src/components/list_group/pinnable_list_group/pinnable_list_group.test.tsx b/src/components/list_group/pinnable_list_group/pinnable_list_group.test.tsx new file mode 100644 index 00000000000..a658231b09b --- /dev/null +++ b/src/components/list_group/pinnable_list_group/pinnable_list_group.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../../test/required_props'; + +import { + EuiPinnableListGroup, + EuiPinnableListGroupItemProps, +} from './pinnable_list_group'; + +const someListItems: EuiPinnableListGroupItemProps[] = [ + { + label: 'Label with iconType', + iconType: 'stop', + }, + { + label: 'Custom extra action', + extraAction: { + iconType: 'bell', + alwaysShow: true, + 'aria-label': 'bell', + }, + }, + { + label: 'Active link', + isActive: true, + href: '#', + }, + { + label: 'Button with onClick', + pinned: true, + onClick: e => { + console.log('Visualize clicked', e); + }, + }, + { + label: 'Link with href', + href: '#', + }, +]; + +describe('EuiPinnableListGroup', () => { + test('is rendered', () => { + const component = render( + {}} + /> + ); + + expect(component).toMatchSnapshot(); + }); + + test('can have custom pin icon titles', () => { + const component = render( + {}} + pinTitle={(item: EuiPinnableListGroupItemProps) => + `Pin ${item.label} to the top` + } + unpinTitle={(item: EuiPinnableListGroupItemProps) => + `Unpin ${item.label} to the top` + } + /> + ); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/components/list_group/pinnable_list_group/pinnable_list_group.tsx b/src/components/list_group/pinnable_list_group/pinnable_list_group.tsx new file mode 100644 index 00000000000..b00accde19f --- /dev/null +++ b/src/components/list_group/pinnable_list_group/pinnable_list_group.tsx @@ -0,0 +1,122 @@ +import React, { FunctionComponent } from 'react'; +import classNames from 'classnames'; +import { CommonProps } from '../../common'; + +import { EuiI18n } from '../../i18n'; +import { EuiListGroup, EuiListGroupProps } from '../list_group'; +import { EuiListGroupItemProps } from '../list_group_item'; + +const pinExtraAction: EuiListGroupItemProps['extraAction'] = { + color: 'primary', + iconType: 'pinFilled', + iconSize: 's', + className: 'euiPinnableListGroup__itemExtraAction', +}; + +const pinnedExtraAction: EuiListGroupItemProps['extraAction'] = { + color: 'primary', + iconType: 'pinFilled', + iconSize: 's', + className: + 'euiPinnableListGroup__itemExtraAction euiPinnableListGroup__itemExtraAction-pinned', + alwaysShow: true, +}; + +export type EuiPinnableListGroupItemProps = EuiListGroupItemProps & { + pinned?: boolean; +}; + +export interface EuiPinnableListGroupProps + extends CommonProps, + EuiListGroupProps { + /** + * Extends `EuiListGroupItemProps`, at the very least, expecting a `label`. + * See #EuiPinnableListGroupItem + */ + listItems: EuiPinnableListGroupItemProps[]; + /** + * Shows the pin icon and calls this function on click. + * Returns `item: EuiPinnableListGroupItemProps` + */ + onPinClick: (item: EuiPinnableListGroupItemProps) => void; + /** + * The pin icon needs a title/aria-label for accessibility. + * It is a function that passes the item back and must return a string `(item) => string`. + * Default is `"Pin item"` + */ + pinTitle?: (item: EuiPinnableListGroupItemProps) => string; + /** + * The unpin icon needs a title/aria-label for accessibility. + * It is a function that passes the item back and must return a string `(item) => string`. + * Default is `"Unpin item"` + */ + unpinTitle?: (item: EuiPinnableListGroupItemProps) => string; +} + +export const EuiPinnableListGroup: FunctionComponent< + EuiPinnableListGroupProps +> = ({ className, listItems, pinTitle, unpinTitle, onPinClick, ...rest }) => { + const classes = classNames('euiPinnableListGroup', className); + + // Alter listItems object with extra props + const getNewListItems = ( + pinExtraActionLabel: string, + pinnedExtraActionLabel: string + ) => + listItems.map(item => { + const { pinned, ...itemProps } = item; + // Make some declarations of props for the nav implementation + itemProps.className = classNames( + 'euiPinnableListGroup__item', + item.className + ); + itemProps.size = item.size || 's'; + + // Add the pinning action unless the item has it's own extra action + if (onPinClick && !itemProps.extraAction) { + // Different displays for pinned vs unpinned + if (pinned) { + itemProps.extraAction = { + ...pinnedExtraAction, + title: unpinTitle ? unpinTitle(item) : pinnedExtraActionLabel, + 'aria-label': unpinTitle + ? unpinTitle(item) + : pinnedExtraActionLabel, + }; + } else { + itemProps.extraAction = { + ...pinExtraAction, + title: pinTitle ? pinTitle(item) : pinnedExtraActionLabel, + 'aria-label': pinTitle ? pinTitle(item) : pinnedExtraActionLabel, + }; + } + // Return the item on click + itemProps.extraAction.onClick = () => onPinClick(item); + } + + return itemProps; + }); + + return ( + + {([pinExtraActionLabel, pinnedExtraActionLabel]: string[]) => { + const newListItems = getNewListItems( + pinExtraActionLabel, + pinnedExtraActionLabel + ); + return ( + + ); + }} + + ); +};