diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b35e7f4..bb4d7d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ #### 0.0.16 +- [#159](https://github.com/influxdata/clockface/pull/159): Port `ResourceList` and `ResourceCard` component families from InfluxDB - [#157](https://github.com/influxdata/clockface/pull/157): Add display names to all components so they are legible despite minification in the React inspector - [#155](https://github.com/influxdata/clockface/pull/155): Add markdown documentation for `Radio` component family - [#153](https://github.com/influxdata/clockface/pull/154): Add markdown documentation for `IndexList` components diff --git a/src/Components/IndexList/IndexListHeaderCell.md b/src/Components/IndexList/IndexListHeaderCell.md index 35a0f4e5..477a7277 100644 --- a/src/Components/IndexList/IndexListHeaderCell.md +++ b/src/Components/IndexList/IndexListHeaderCell.md @@ -23,7 +23,7 @@ IndexListHeaderCells have some handy features that making sorting easier. First | `sortDirection` | `Sort` | Keeps track of which direction sorting is happening in | | `items` | `[]` | List of items; enables sorting and/or filtering | -Next pass a handler function into each `` you want to be sortable: +Next, pass a handler function into each `` you want to be sortable: ```js private handleSort = (nextSort: Sort, sortKey: string): void => { diff --git a/src/Components/Label/Label.scss b/src/Components/Label/Label.scss index a3f20f70..d5425d8b 100644 --- a/src/Components/Label/Label.scss +++ b/src/Components/Label/Label.scss @@ -20,6 +20,7 @@ font-weight: 700; white-space: nowrap; margin: 0; + font-style: normal; } .label--clickable { diff --git a/src/Components/Label/Label.tsx b/src/Components/Label/Label.tsx index abace794..5c9e4a39 100644 --- a/src/Components/Label/Label.tsx +++ b/src/Components/Label/Label.tsx @@ -4,12 +4,12 @@ import chroma from 'chroma-js' import classnames from 'classnames' // Types -import {ComponentSize, InfluxColors} from '../../Types' +import {StandardProps, ComponentSize, InfluxColors} from '../../Types' // Styles import './Label.scss' -interface Props { +interface Props extends StandardProps { /** Unique value to be returned when Label is clicked */ id: string /** Name of the Label, appears inside the label */ @@ -24,10 +24,6 @@ interface Props { onDelete?: (id: string) => void /** Size of Label */ size: ComponentSize - /** Test ID for Integration Tests */ - testID: string - /** Class name for custom styles */ - className?: string } interface State { diff --git a/src/Components/ResourceList/Card/ResourceCard.md b/src/Components/ResourceList/Card/ResourceCard.md new file mode 100644 index 00000000..6dd07f5c --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCard.md @@ -0,0 +1,19 @@ +# ResourceCard + +`ResourceCard` is the parent component of the `ResourceCard` Family; every member of the component family can be accessed from this single import. `ResourceCard` is best used in tandem with `ResourceList`. + +### Usage +```js +import {ResourceCard} from '@influxdata/clockface' +``` + +Most of ResourceCard's props are render props which help enforce an opinionated structure on the layout. This ensures that if a card has all of its render props passed in, the components look nice together. While any component can be passed into the render props, we recommend using the components included in the ResourceCard family. + +### Example + + + + + + + diff --git a/src/Components/ResourceList/Card/ResourceCard.scss b/src/Components/ResourceList/Card/ResourceCard.scss new file mode 100644 index 00000000..72d2ffb0 --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCard.scss @@ -0,0 +1,125 @@ +@import "../../../Styles/modules"; + +/* + Resource Card + ------------------------------------------------------------------------------ +*/ + +$resource-card--row-gap: $cf-marg-a - ($cf-border / 2); + +.resource-card { + width: 100%; +} + +.resource-card, +.resource-card--contents { + display: flex; +} + +.resource-card { + flex-direction: row; + align-items: stretch; + position: relative; + color: $g13-mist; + background-color: $g3-castle; + border-radius: $cf-radius; + padding: $cf-marg-b $cf-marg-c; + padding-bottom: $cf-marg-b - $resource-card--row-gap; + margin-bottom: $cf-border; + transition: background-color 0.25s ease, color 0.25s ease; + + &:hover { + color: $g16-pearl; + background-color: $g4-onyx; + } +} + +.resource-card--contents { + flex-direction: column; + flex: 1 0 0; +} + +.resource-card--row { + margin-bottom: $resource-card--row-gap; +} + +.resource-list--toggle { + padding-right: $cf-marg-c; +} + +.resource-list--meta { + display: flex; + align-items: center; + font-size: $form-xs-font; + font-weight: 600; + color: $g11-sidewalk; + padding-bottom: $cf-border; +} + +.resource-list--meta-item { + transition: border-color 0.25s ease, color 0.25s ease; + border-right: $cf-border solid $g5-pepper; + padding-right: $cf-marg-b + $cf-marg-a; + margin-right: $cf-marg-b + $cf-marg-a; + + &:last-child { + border-right: 0; + padding-right: 0; + margin-right: 0; + } + + .resource-card:hover & { + color: $g15-platinum; + border-color: $g7-graphite; + } +} + +.resource-list--context-menu { + opacity: 0; + transition: opacity 0.25s ease; + position: absolute; + top: $cf-marg-b; + right: $cf-marg-b; + + .resource-card:hover & { + opacity: 1; + } +} + +/* + Disabled Card + ------------------------------------------------------------------------------ +*/ + +.resource-card.resource-card__disabled { + background-color: rgba($g3-castle, 0.5); + color: $g9-mountain; + font-style: italic; + + .resource-list--meta { + color: $g9-mountain; + } + + &:hover .resource-list--meta { + color: $g12-forge; + } +} + +/* + Depth Styling + ------------------------------------------------------------------------------ +*/ + +.panel .resource-card, +.tabs .resource-card { + background-color: $g4-onyx; + + &:hover { + background-color: $g5-pepper; + } +} + +.panel .resource-card.resource-card__disabled, +.tabs .resource-card.resource-card__disabled { + background-color: rgba($g4-onyx, 0.5); +} diff --git a/src/Components/ResourceList/Card/ResourceCard.tsx b/src/Components/ResourceList/Card/ResourceCard.tsx new file mode 100644 index 00000000..09f54103 --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCard.tsx @@ -0,0 +1,115 @@ +// Libraries +import React, {PureComponent} from 'react' +import classnames from 'classnames' + +// Components +import {ResourceCardName} from './ResourceCardName' +import {ResourceCardEditableName} from './ResourceCardEditableName' +import {ResourceCardDescription} from './ResourceCardDescription' + +// Types +import {StandardProps} from '../../../Types' + +// Styles +import './ResourceCard.scss' + +interface Props extends StandardProps { + /** Renders the name component in its designated place */ + name: JSX.Element + /** Renders the card with disabled styles */ + disabled?: boolean + /** Renders the description component in its designated place */ + description?: JSX.Element + /** Renders the labelling components in their designated place */ + labels?: JSX.Element + /** Renders horizontal list of meta items in their designated place */ + metaData?: JSX.Element[] + /** Renders the context menu component in its designated place */ + contextMenu?: JSX.Element + /** Renders the toggle component in its designated place */ + toggle?: JSX.Element +} + +export class ResourceCard extends PureComponent { + public static readonly displayName = 'ResourceCard' + + public static defaultProps = { + testID: 'resource-card', + } + + public static Name = ResourceCardName + public static EditableName = ResourceCardEditableName + public static Description = ResourceCardDescription + + public render() { + const {description, labels, children, testID, name} = this.props + + return ( +
+ {this.toggle} +
+
{name}
+ {description ? ( +
{description}
+ ) : null} + {this.formattedMetaData} + {labels ?
{labels}
: null} + {children ? ( +
{children}
+ ) : null} +
+ {this.contextMenu} +
+ ) + } + + private get className(): string { + const {disabled, className} = this.props + + return classnames('resource-card', { + 'resource-card__disabled': disabled, + [`${className}`]: className, + }) + } + + private get toggle(): JSX.Element | undefined { + const {toggle} = this.props + + if (toggle) { + return
{toggle}
+ } + + return + } + + private get formattedMetaData(): JSX.Element | undefined { + const {metaData} = this.props + + if (metaData) { + return ( +
+ {React.Children.map(metaData, (metaItem: JSX.Element) => ( +
+ {metaItem} +
+ ))} +
+ ) + } + + return + } + + private get contextMenu(): JSX.Element | undefined { + const {contextMenu} = this.props + + if (contextMenu) { + return
{contextMenu}
+ } + + return + } +} diff --git a/src/Components/ResourceList/Card/ResourceCardDescription.md b/src/Components/ResourceList/Card/ResourceCardDescription.md new file mode 100644 index 00000000..0d455437 --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCardDescription.md @@ -0,0 +1,24 @@ +# ResourceCardDescription + +`ResourceCardDescription` is intended to be passed into the `description` prop in `ResourceCard`. It can be accessed via the single `ResourceCard` import as a subclass. + +### Usage +```js +import {ResourceCard} from '@influxdata/clockface' +``` +```js + +``` + +### Interactions + +This component facilitates changes to saved text via keyboard and mouse. There are 2 modes: view and edit. When in edit mode a text input is visible and always focused. Hitting `enter` or blurring the input will cause it to fire `onUpdate` and return to view mode. + +### Example + + + + + + + diff --git a/src/Components/ResourceList/Card/ResourceCardDescription.scss b/src/Components/ResourceList/Card/ResourceCardDescription.scss new file mode 100644 index 00000000..9f3357c8 --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCardDescription.scss @@ -0,0 +1,78 @@ +@import "../../../Styles/modules.scss"; + +/* + Resource Editable Description + ------------------------------------------------------------------------------ +*/ + +.resource-description { + width: 100%; + min-height: $form-xs-height; +} + +.resource-description--preview, +.input.resource-description--input > input { + font-size: $form-xs-font; + font-weight: 500; + font-family: $cf-text-font; +} + +.resource-description--preview, +.resource-description--input { + position: relative; + width: 100%; +} + +.resource-description--preview { + padding-top: $cf-marg-a; + width: auto; + display: inline-block; + border-radius: $cf-radius; + position: relative; + overflow: hidden; + @include no-user-select(); + color: $g13-mist; + transition: color 0.25s ease, background-color 0.25s ease, + border-color 0.25s ease; + line-height: $form-xs-font + $cf-border; + + .icon { + position: relative; + top: -2px; + display: inline-block; + margin-left: $cf-marg-b; + opacity: 0; + transition: opacity 0.25s ease; + color: $g11-sidewalk; + } + + &:hover .icon { + opacity: 1; + } + + &.untitled { + color: $g9-mountain; + font-style: italic; + } + + &:hover { + cursor: text; + color: $g20-white; + } +} + +/* Ensure placeholder text matches font weight of title */ +.input.resource-description--input > input { + &::-webkit-input-placeholder { + font-weight: 500 !important; + } + &::-moz-placeholder { + font-weight: 500 !important; + } + &:-ms-input-placeholder { + font-weight: 500 !important; + } + &:-moz-placeholder { + font-weight: 500 !important; + } +} diff --git a/src/Components/ResourceList/Card/ResourceCardDescription.tsx b/src/Components/ResourceList/Card/ResourceCardDescription.tsx new file mode 100644 index 00000000..5442d5ad --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCardDescription.tsx @@ -0,0 +1,143 @@ +// Libraries +import React, {Component, KeyboardEvent, ChangeEvent} from 'react' +import classnames from 'classnames' + +// Components +import {Input} from '../../Inputs/Input' +import {Icon} from '../../Icon/Icon' +import {ClickOutside} from '../../ClickOutside/ClickOutside' + +// Types +import {StandardProps, ComponentSize, IconFont} from '../../../Types' + +// Styles +import './ResourceCardDescription.scss' + +interface Props extends StandardProps { + /** Text to display in description */ + description: string + /** Placeholder text to display in input during editing */ + placeholder: string + /** Called when user hits enter or blurs the input */ + onUpdate: (description: string) => void +} + +interface State { + isEditing: boolean + workingDescription: string +} + +export class ResourceCardDescription extends Component { + public static readonly displayName = 'ResourceCardDescription' + + public static defaultProps = { + testID: 'resource-list--description', + placeholder: '', + } + + constructor(props: Props) { + super(props) + + this.state = { + isEditing: false, + workingDescription: props.description, + } + } + + public render() { + const {description, testID} = this.props + const {isEditing} = this.state + + if (isEditing) { + return ( +
+ + {this.input} + +
+ ) + } + + return ( +
+
+ {description || 'No description'} + +
+
+ ) + } + + private get className(): string { + const {className} = this.props + + return classnames('resource-description', {[`${className}`]: className}) + } + + private get input(): JSX.Element { + const {placeholder} = this.props + const {workingDescription} = this.state + + return ( + + ) + } + + private handleStartEditing = (): void => { + this.setState({isEditing: true}) + } + + private handleStopEditing = async (): Promise => { + const {workingDescription} = this.state + const {onUpdate} = this.props + + await onUpdate(workingDescription) + + this.setState({isEditing: false}) + } + + private handleInputChange = (e: ChangeEvent): void => { + this.setState({workingDescription: e.target.value}) + } + + private handleKeyDown = async ( + e: KeyboardEvent + ): Promise => { + const {onUpdate, description} = this.props + const {workingDescription} = this.state + + if (e.key === 'Enter') { + await onUpdate(workingDescription) + this.setState({isEditing: false}) + } + + if (e.key === 'Escape') { + this.setState({isEditing: false, workingDescription: description}) + } + } + + private handleInputFocus = (e: ChangeEvent): void => { + e.currentTarget.select() + } + + private get previewClassName(): string { + const {description} = this.props + + return classnames('resource-description--preview', { + untitled: !description, + }) + } +} diff --git a/src/Components/ResourceList/Card/ResourceCardEditableName.md b/src/Components/ResourceList/Card/ResourceCardEditableName.md new file mode 100644 index 00000000..dec82b15 --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCardEditableName.md @@ -0,0 +1,24 @@ +# ResourceCardEditableName + +`ResourceCardEditableName` is intended to be passed into the `name` prop in `ResourceCard`. It can be accessed via the single `ResourceCard` import as a subclass. + +### Usage +```js +import {ResourceCard} from '@influxdata/clockface' +``` +```js + +``` + +### Interactions + +This component facilitates changes to saved text via keyboard and mouse. There are 2 modes: view and edit. When in edit mode a text input is visible and always focused. Hitting `enter` or blurring the input will cause it to fire `onUpdate` and return to view mode. + +### Example + + + + + + + diff --git a/src/Components/ResourceList/Card/ResourceCardEditableName.scss b/src/Components/ResourceList/Card/ResourceCardEditableName.scss new file mode 100644 index 00000000..bd1f50e7 --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCardEditableName.scss @@ -0,0 +1,78 @@ +@import "../../../Styles/modules.scss"; + +/* + Editable Name for Resource Cards + ------------------------------------------------------------------------------ +*/ + +$resource-name-font-size: 17px; +$resource-name-font-weight: 600; + +.resource-name--link, +.input.resource-editable-name--input > input { + font-size: $resource-name-font-size; + font-weight: $resource-name-font-weight; + font-family: $cf-text-font; +} + +.resource-editable-name > a { + display: inline-block; + white-space: nowrap; + line-height: $form-xs-height; +} + +.resource-editable-name { + height: $form-xs-height; + position: relative; + display: inline-flex; + flex-wrap: nowrap; +} + +.input.resource-editable-name--input { + position: absolute; + top: 0; + left: 0; + width: 100%; +} + +/* Ensure placeholder text matches font weight of title */ +.input.resource-editable-name--input > input { + &::-webkit-input-placeholder { + font-weight: $resource-name-font-weight !important; + } + &::-moz-placeholder { + font-weight: $resource-name-font-weight !important; + } + &:-ms-input-placeholder { + font-weight: $resource-name-font-weight !important; + } + &:-moz-placeholder { + font-weight: $resource-name-font-weight !important; + } +} + +/* Edit button hover behavior */ +.resource-editable-name--toggle { + font-size: $resource-name-font-size * 0.75; + transform: translateY(-10%); + padding: $cf-marg-a; + display: inline-block; + margin-left: $cf-marg-b; + transition: color 0.25s ease, opacity 0.25s ease, width 0.25s ease; + opacity: 0; + width: 0; + color: $g11-sidewalk; + + &:hover { + cursor: pointer; + color: $g15-platinum; + } +} + +.resource-editable-name:hover, +.resource-editable-name--editing { + .resource-editable-name--toggle { + opacity: 1; + width: $cf-marg-b + $cf-marg-c; + } +} diff --git a/src/Components/ResourceList/Card/ResourceCardEditableName.tsx b/src/Components/ResourceList/Card/ResourceCardEditableName.tsx new file mode 100644 index 00000000..e7ee944b --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCardEditableName.tsx @@ -0,0 +1,173 @@ +// Libraries +import React, {Component, KeyboardEvent, ChangeEvent, MouseEvent} from 'react' +import classnames from 'classnames' + +// Components +import {Input} from '../../Inputs/Input' +import {SpinnerContainer} from '../../Spinners/SpinnerContainer' +import {TechnoSpinner} from '../../Spinners/TechnoSpinner' +import {ClickOutside} from '../../ClickOutside/ClickOutside' + +// Types +import {StandardProps, ComponentSize, RemoteDataState} from '../../../Types' + +// Styles +import './ResourceCardEditableName.scss' + +interface Props extends StandardProps { + /** Called when editing is finished, new name is passed */ + onUpdate: (name: string) => void + /** Text to display as name */ + name: string + /** Fires when the name itself is clicked and not edited */ + onClick?: (e: MouseEvent) => void + /** Placeholder text to display in input during editing */ + placeholder?: string + /** Text to display when not editing and when no name is present */ + noNameString: string + /** TestID for edit button sub-component */ + buttonTestID: string + /** TestID for input sub-component */ + inputTestID: string +} + +interface State { + isEditing: boolean + workingName: string + loading: RemoteDataState +} + +export class ResourceCardEditableName extends Component { + public static readonly displayName = 'ResourceCardEditableName' + + public static defaultProps = { + testID: 'resource-editable-name', + buttonTestID: 'resource-editable-name--button', + inputTestID: 'resource-editable-name--input', + } + + constructor(props: Props) { + super(props) + + this.state = { + isEditing: false, + workingName: props.name, + loading: RemoteDataState.Done, + } + } + + public render() { + const {name, noNameString, testID, buttonTestID} = this.props + + return ( +
+ } + > + + {name || noNameString} + + +
+ +
+ {this.input} +
+ ) + } + + private get input(): JSX.Element | undefined { + const {placeholder, inputTestID} = this.props + const {workingName, isEditing, loading} = this.state + + if (isEditing && loading !== RemoteDataState.Loading) { + return ( + + + + ) + } + + return + } + + private handleClick = (e: MouseEvent) => { + const {onClick} = this.props + if (onClick) { + onClick(e) + } + } + + private handleStartEditing = (): void => { + this.setState({isEditing: true}) + } + + private handleStopEditing = async (): Promise => { + const {workingName} = this.state + const {onUpdate} = this.props + + this.setState({loading: RemoteDataState.Loading}) + await onUpdate(workingName) + this.setState({loading: RemoteDataState.Done, isEditing: false}) + } + + private handleInputChange = (e: ChangeEvent): void => { + this.setState({workingName: e.target.value}) + } + + private handleKeyDown = async ( + e: KeyboardEvent + ): Promise => { + const {onUpdate, name} = this.props + const {workingName} = this.state + + if (e.key === 'Enter') { + e.persist() + + if (!workingName) { + this.setState({isEditing: false, workingName: name}) + + return + } + this.setState({loading: RemoteDataState.Loading}) + await onUpdate(workingName) + this.setState({isEditing: false, loading: RemoteDataState.Done}) + } + + if (e.key === 'Escape') { + this.setState({isEditing: false, workingName: name}) + } + } + + private handleInputFocus = (e: ChangeEvent): void => { + e.currentTarget.select() + } + + private get className(): string { + const {name, noNameString, className} = this.props + const {isEditing} = this.state + + return classnames('resource-editable-name', { + 'resource-editable-name--editing': isEditing, + 'untitled-name': name === noNameString, + [`${className}`]: className, + }) + } +} diff --git a/src/Components/ResourceList/Card/ResourceCardName.md b/src/Components/ResourceList/Card/ResourceCardName.md new file mode 100644 index 00000000..4301b67c --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCardName.md @@ -0,0 +1,20 @@ +# ResourceCardName + +`ResourceCardName` is intended to be passed into the `name` prop in `ResourceCard`. It can be accessed via the single `ResourceCard` import as a subclass. + +### Usage +```js +import {ResourceCard} from '@influxdata/clockface' +``` +```js + +``` + +### Example + + + + + + + diff --git a/src/Components/ResourceList/Card/ResourceCardName.scss b/src/Components/ResourceList/Card/ResourceCardName.scss new file mode 100644 index 00000000..26ae585c --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCardName.scss @@ -0,0 +1,37 @@ +@import "../../../Styles/modules.scss"; + +/* + Editable Name for Resource Cards + ------------------------------------------------------------------------------ +*/ + +$resource-name-font-size: 17px; +$resource-name-font-weight: 600; + +.resource-name { + height: $form-xs-height; + position: relative; + display: inline-flex; + flex-wrap: nowrap; +} + +.resource-name--link { + font-size: $resource-name-font-size; + font-weight: $resource-name-font-weight; + font-family: $cf-text-font; + display: inline-block; + white-space: nowrap; + line-height: $form-xs-height; + color: $cf-link-default; + transition: color 0.25s ease; + font-weight: $cf-link-weight; + + .resource-card__disabled & { + color: rgba($cf-link-default, 0.5); + } + + &:hover { + cursor: pointer; + color: $cf-link-default-hover; + } +} diff --git a/src/Components/ResourceList/Card/ResourceCardName.tsx b/src/Components/ResourceList/Card/ResourceCardName.tsx new file mode 100644 index 00000000..94ec2d43 --- /dev/null +++ b/src/Components/ResourceList/Card/ResourceCardName.tsx @@ -0,0 +1,42 @@ +// Libraries +import React, {Component, MouseEvent} from 'react' + +// Types +import {StandardProps} from '../../../Types' + +// Styles +import './ResourceCardName.scss' + +interface Props extends StandardProps { + /** Text to display as name */ + name: string + /** Fires when the name is clicked */ + onClick?: (e: MouseEvent) => void +} + +export class ResourceCardName extends Component { + public static readonly displayName = 'ResourceCardName' + + public static defaultProps = { + testID: 'resource-name', + } + + public render() { + const {name, testID} = this.props + + return ( +
+ + {name} + +
+ ) + } + + private handleClick = (e: MouseEvent) => { + const {onClick} = this.props + if (onClick) { + onClick(e) + } + } +} diff --git a/src/Components/ResourceList/List/ResourceList.md b/src/Components/ResourceList/List/ResourceList.md new file mode 100644 index 00000000..890727be --- /dev/null +++ b/src/Components/ResourceList/List/ResourceList.md @@ -0,0 +1,17 @@ +# ResourceList + +`ResourceList` is the parent component of the `ResourceList` Family; every member of the component family can be accessed from this single import. `ResourceList` is best used in tandem with `ResourceCard`. + +### Usage +```js +import {ResourceList} from '@influxdata/clockface' +``` + +### Example + + + + + + + diff --git a/src/Components/ResourceList/List/ResourceList.scss b/src/Components/ResourceList/List/ResourceList.scss new file mode 100644 index 00000000..c01e611a --- /dev/null +++ b/src/Components/ResourceList/List/ResourceList.scss @@ -0,0 +1,93 @@ +@import "../../../Styles/modules"; + +/* + Resource List + ------------------------------------------------------------------------------ +*/ + +.resource-list { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.resource-list--header, +.resource-list--body { + width: 100%; +} + +/* + Header & Sorting + ------------------------------------------------------------------------------ +*/ + +.resource-list--header { + display: flex; + align-items: center; + padding-bottom: $cf-marg-c; + justify-content: space-between; + width: 100%; +} + +.resource-list--sorting { + display: flex; + align-items: center; +} + +.resource-list--filter { + min-width: 10px; +} + +.resource-list--sorter { + user-select: none; + font-size: $form-md-font; + font-weight: 600; + text-transform: uppercase; + color: $g11-sidewalk; + transition: color 0.25s ease; + margin-left: $cf-marg-c; + display: flex; + align-items: center; + align-content: center; + + &:hover { + color: $g18-cloud; + cursor: pointer; + } + + &.resource-list--sort-descending, + &.resource-list--sort-ascending { + color: $c-pool; + } +} + +.resource-list--sort-arrow { + width: $cf-marg-c; + height: $cf-marg-c; + position: relative; + margin-left: $cf-marg-a; + transition: opacity 0.25s ease; + opacity: 0; + + .resource-list--sort-descending &, + .resource-list--sort-ascending & { + opacity: 1; + } + + > span.icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + transition: transform 0.25s ease; + } + + .resource-list--sort-descending & > span.icon { + transform: translate(-50%, -50%) rotate(0deg); + } + + .resource-list--sort-ascending & > span.icon { + transform: translate(-50%, -50%) rotate(180deg); + } +} diff --git a/src/Components/ResourceList/List/ResourceList.tsx b/src/Components/ResourceList/List/ResourceList.tsx new file mode 100644 index 00000000..4862068a --- /dev/null +++ b/src/Components/ResourceList/List/ResourceList.tsx @@ -0,0 +1,44 @@ +// Libraries +import React, {PureComponent} from 'react' +import classnames from 'classnames' + +// Components +import {ResourceListHeader} from './ResourceListHeader' +import {ResourceListSorter} from './ResourceListSorter' +import {ResourceListBody} from './ResourceListBody' + +// Types +import {StandardProps} from '../../../Types' + +// Styles +import './ResourceList.scss' + +interface Props extends StandardProps {} + +export class ResourceList extends PureComponent { + public static readonly displayName = 'ResourceList' + + public static defaultProps = { + testID: 'resource-list', + } + + public static Header = ResourceListHeader + public static Sorter = ResourceListSorter + public static Body = ResourceListBody + + public render() { + const {children, testID} = this.props + + return ( +
+ {children} +
+ ) + } + + private get className(): string { + const {className} = this.props + + return classnames('resource-list', {[`${className}`]: className}) + } +} diff --git a/src/Components/ResourceList/List/ResourceListBody.md b/src/Components/ResourceList/List/ResourceListBody.md new file mode 100644 index 00000000..4facf5e9 --- /dev/null +++ b/src/Components/ResourceList/List/ResourceListBody.md @@ -0,0 +1,24 @@ +# ResourceList + +`ResourceListBody` is intended to be the second child of `ResourceList`, similar to `` in a plain HTML table. It can be accessed via the single `ResourceList` import as a subclass. + +### Usage +```js +import {ResourceList} from '@influxdata/clockface' +``` +```js + + // Children + +``` + +We recommend using `ResourceCard` as the child component type. + +### Example + + + + + + + diff --git a/src/Components/ResourceList/List/ResourceListBody.tsx b/src/Components/ResourceList/List/ResourceListBody.tsx new file mode 100644 index 00000000..8007afa8 --- /dev/null +++ b/src/Components/ResourceList/List/ResourceListBody.tsx @@ -0,0 +1,49 @@ +// Libraries +import React, {PureComponent, ReactNode} from 'react' +import classnames from 'classnames' + +// Types +import {StandardProps} from '../../../Types' + +interface Props extends StandardProps { + /** Element to show when no children are passed in, useful for implementing filtering */ + emptyState: JSX.Element +} + +export class ResourceListBody extends PureComponent { + public static readonly displayName = 'ResourceListBody' + + public static defaultProps = { + testID: 'resource-list--body', + } + + public render() { + const {testID} = this.props + + return ( +
+ {this.children} +
+ ) + } + + private get children(): JSX.Element | ReactNode { + const {children, emptyState} = this.props + + if ( + React.Children.count(children) === 0 || + children === undefined || + children === null + ) { + return emptyState + } + + return children + } + + private get className(): string { + const {className} = this.props + + return classnames('resource-list--body', {[`${className}`]: className}) + } +} diff --git a/src/Components/ResourceList/List/ResourceListHeader.md b/src/Components/ResourceList/List/ResourceListHeader.md new file mode 100644 index 00000000..e791e147 --- /dev/null +++ b/src/Components/ResourceList/List/ResourceListHeader.md @@ -0,0 +1,24 @@ +# ResourceListHeader + +`ResourceListHeader` is intended to be the first child of `ResourceList`, similar to `` in a plain HTML table. It can be accessed via the single `ResourceList` import as a subclass. + +### Usage +```js +import {ResourceList} from '@influxdata/clockface' +``` +```js + + // Children + +``` + +We recommend using `ResourceList.Sorter` as the child component type. + +### Example + + + + + + + diff --git a/src/Components/ResourceList/List/ResourceListHeader.tsx b/src/Components/ResourceList/List/ResourceListHeader.tsx new file mode 100644 index 00000000..e6774823 --- /dev/null +++ b/src/Components/ResourceList/List/ResourceListHeader.tsx @@ -0,0 +1,46 @@ +// Libraries +import React, {PureComponent} from 'react' +import classnames from 'classnames' + +// Types +import {StandardProps} from '../../../Types' + +interface Props extends StandardProps { + /** Used for rendering a filter input above the list, opposite the sort headers */ + filterComponent?: JSX.Element +} + +export class ResourceListHeader extends PureComponent { + public static readonly displayName = 'ResourceListHeader' + + public static defaultProps = { + testID: 'resource-list--header', + } + + public render() { + const {children, testID} = this.props + + return ( +
+ {this.filter} +
{children}
+
+ ) + } + + private get filter(): JSX.Element { + const {filterComponent} = this.props + + if (filterComponent) { + return
{filterComponent}
+ } + + return
+ } + + private get className(): string { + const {className} = this.props + + return classnames('resource-list--header', {[`${className}`]: className}) + } +} diff --git a/src/Components/ResourceList/List/ResourceListSorter.md b/src/Components/ResourceList/List/ResourceListSorter.md new file mode 100644 index 00000000..75ff4d04 --- /dev/null +++ b/src/Components/ResourceList/List/ResourceListSorter.md @@ -0,0 +1,61 @@ +# ResourceListSorter + +`ResourceListSorter` is a sortable header intended for use as a child of `ResourceListHeader`. It can be accessed via the single `ResourceList` import as a subclass. + +### Usage +```js +import {ResourceList} from '@influxdata/clockface' +``` +```js + +``` + +### Example + + +### Enabling Sorting + +`ResourceListSorter`s have some handy features that making sorting easier. First you will need a stateful component that wraps `ResourceList`. This stateful component should have at least 3 pieces of state: + +| State Key | Type | | +|:-----------------|:--------|----------------------------------------------------------------------------------| +| `sortKey` | `string` | The identifier for the currently sorted column, can be `null` if no sort applied | +| `sortDirection` | `Sort` | Keeps track of which direction sorting is happening in | +| `items` | `[]` | List of items; enables sorting and/or filtering | + +Next, pass a handler function into each `` you want to be sortable: + +```js +private handleSort = (nextSort: Sort, sortKey: string): void => { + this.setState({ + sortDirection: nextSort, + sortKey, + }) +} +``` +```js + +``` + +When a sorter is clicked it cycles to the next available sort state and passes that back. This ensures that sort states are cycled through in a consistent manner. + +Make sure each each `` receives state: + +```js +const {sortKey, sortDirection, items} = this.state +``` +```js +items.map(item => ( + +)) +``` + + + + + + diff --git a/src/Components/ResourceList/List/ResourceListSorter.tsx b/src/Components/ResourceList/List/ResourceListSorter.tsx new file mode 100644 index 00000000..fe1f89df --- /dev/null +++ b/src/Components/ResourceList/List/ResourceListSorter.tsx @@ -0,0 +1,93 @@ +// Libraries +import React, {PureComponent} from 'react' +import classnames from 'classnames' + +// Types +import {Sort, StandardProps} from '../../../Types' + +interface Props extends StandardProps { + /** Controls appearance of sort indicator (arrow) */ + sort: Sort + /** Unique identifier for use in managing sort state */ + sortKey: string + /** Name of attribute this element sorts on */ + name: string + /** Useful for triggering a change in sort state */ + onClick?: (nextSort: Sort, sortKey: string) => void +} + +export class ResourceListSorter extends PureComponent { + public static readonly displayName = 'ResourceListSorter' + + public static defaultProps = { + testID: 'resource-list--sorter', + } + + public render() { + const {name, testID} = this.props + + return ( +
+ {name} + {this.sortIndicator} +
+ ) + } + + private handleClick = (): void => { + const {onClick, sort, sortKey} = this.props + + if (!onClick || !sort) { + return + } + + if (sort === Sort.None) { + onClick(Sort.Ascending, sortKey) + } else if (sort === Sort.Ascending) { + onClick(Sort.Descending, sortKey) + } else if (sort === Sort.Descending) { + onClick(Sort.None, sortKey) + } + } + + private get title(): string | undefined { + const {sort, name} = this.props + + if (sort === Sort.None) { + return `Sort ${name} in ${Sort.Ascending} order` + } else if (sort === Sort.Ascending) { + return `Sort ${name} in ${Sort.Descending} order` + } + + return + } + + private get sortIndicator(): JSX.Element | undefined { + const {onClick} = this.props + + if (onClick) { + return ( + + + + ) + } + + return + } + + private get className(): string { + const {sort, className} = this.props + + return classnames('resource-list--sorter', { + 'resource-list--sort-descending': sort === Sort.Descending, + 'resource-list--sort-ascending': sort === Sort.Ascending, + [`${className}`]: className, + }) + } +} diff --git a/src/Components/ResourceList/ResourceCardExample.md b/src/Components/ResourceList/ResourceCardExample.md new file mode 100644 index 00000000..ca187885 --- /dev/null +++ b/src/Components/ResourceList/ResourceCardExample.md @@ -0,0 +1,17 @@ +# Toggleable Card + +This example shows what a `ResourceCard` looks like when a component is passed into the `toggle`, `labels` and `contextMenu` props. + +### Usage +```js +import {ResourceCard, SquareButton, SlideToggle, Label} from '@influxdata/clockface' +``` + +### Example + + + + + + + diff --git a/src/Components/ResourceList/ResourceList.stories.tsx b/src/Components/ResourceList/ResourceList.stories.tsx new file mode 100644 index 00000000..0bd383c3 --- /dev/null +++ b/src/Components/ResourceList/ResourceList.stories.tsx @@ -0,0 +1,474 @@ +// Libraries +import * as React from 'react' +import marked from 'marked' + +// Storybook +import {storiesOf} from '@storybook/react' +import {jsxDecorator} from 'storybook-addon-jsx' +import { + withKnobs, + text, + boolean, + array, + select, + object, + number, +} from '@storybook/addon-knobs' +import {mapEnumKeys} from '../../../.storybook/utils' + +// Components +import {ResourceList} from './List/ResourceList' +import {ResourceListHeader} from './List/ResourceListHeader' +import {ResourceListBody} from './List/ResourceListBody' +import {ResourceListSorter} from './List/ResourceListSorter' +import {ResourceCard} from './Card/ResourceCard' +import {ResourceCardName} from './Card/ResourceCardName' +import {ResourceCardEditableName} from './Card/ResourceCardEditableName' +import {ResourceCardDescription} from './Card/ResourceCardDescription' +import {Input} from '../Inputs/Input' +import {EmptyState} from '../EmptyState/EmptyState' +import {SlideToggle} from '../SlideToggle/SlideToggle' +import {SquareButton} from '../Button/Composed/SquareButton' +import {Label} from '../Label/Label' +import {ComponentSpacer} from '../ComponentSpacer/ComponentSpacer' + +// Types +import { + Sort, + IconFont, + ComponentSize, + ComponentColor, + FlexDirection, +} from '../../Types' + +// Notes +const ResourceListReadme = marked(require('./List/ResourceList.md')) +const ResourceListHeaderReadme = marked(require('./List/ResourceListHeader.md')) +const ResourceListBodyReadme = marked(require('./List/ResourceListBody.md')) +const ResourceListSorterReadme = marked(require('./List/ResourceListSorter.md')) +const ResourceCardReadme = marked(require('./Card/ResourceCard.md')) +const ResourceCardDescriptionReadme = marked( + require('./Card/ResourceCardDescription.md') +) +const ResourceCardNameReadme = marked(require('./Card/ResourceCardName.md')) +const ResourceCardEditableNameReadme = marked( + require('./Card/ResourceCardEditableName.md') +) +const ResourceListExampleReadme = marked(require('./ResourceListExample.md')) +const ResourceCardExampleReadme = marked(require('./ResourceCardExample.md')) + +const indexListStories = storiesOf( + 'Components|ResourceList/List Family', + module +) + .addDecorator(withKnobs) + .addDecorator(jsxDecorator) + +const indexListCardStories = storiesOf( + 'Components|ResourceList/Card Family', + module +) + .addDecorator(withKnobs) + .addDecorator(jsxDecorator) + +const indexListExampleStories = storiesOf( + 'Components|ResourceList/Examples', + module +) + .addDecorator(withKnobs) + .addDecorator(jsxDecorator) + +indexListStories.add( + 'ResourceList', + () => ( +
+ +
+ ), + { + readme: { + content: ResourceListReadme, + }, + } +) + +const exampleHeaderSorts = [ + { + key: 'name', + name: 'Name', + }, + { + key: 'created_at', + name: 'Created At', + }, + { + key: 'color', + name: 'Color', + }, +] + +indexListStories.add( + 'ResourceListHeader', + () => ( +
+ + Filter Input goes here +
+ } + > + {exampleHeaderSorts.map(header => ( + {}} + /> + ))} + +
+ ), + { + readme: { + content: ResourceListHeaderReadme, + }, + } +) + +indexListStories.add( + 'ResourceListBody', + () => ( +
+ EmptyState goes here
+ } + /> + + ), + { + readme: { + content: ResourceListBodyReadme, + }, + } +) + +indexListStories.add( + 'ResourceListSorter', + () => ( +
+ + alert(`onClick fired! Next sort is: "${nextSort}"`) + } + sort={Sort[select('sort', mapEnumKeys(Sort), 'None')]} + sortKey={text('sortKey', 'created_at')} + /> +
+ ), + { + readme: { + content: ResourceListSorterReadme, + }, + } +) + +const resourceCardMeta = ['Created by Bob', 'Updated 25m ago'] + +indexListCardStories.add( + 'ResourceCard', + () => ( +
+ alert(' onClick fired!')} + /> + } + description={ + + alert( + ` onUpdate fired with "${description}"` + ) + } + placeholder={text('description placeholder', 'Enter a description')} + /> + } + disabled={boolean('disabled', false)} + metaData={array('metaData', resourceCardMeta).map(meta => ( + {meta} + ))} + /> +
+ ), + { + readme: { + content: ResourceCardReadme, + }, + } +) + +indexListCardStories.add( + 'ResourceCardDescription', + () => ( +
+ alert(`onUpdate fired with "${description}"`)} + placeholder={text('description placeholder', 'Enter a description')} + /> +
+ ), + { + readme: { + content: ResourceCardDescriptionReadme, + }, + } +) + +indexListCardStories.add( + 'ResourceCardName', + () => ( +
+ alert('onClick fired!')} + /> +
+ ), + { + readme: { + content: ResourceCardNameReadme, + }, + } +) + +indexListCardStories.add( + 'ResourceCardEditableName', + () => ( +
+ alert('onClick fired!')} + onUpdate={name => alert(`onUpdate fired with "${name}"`)} + noNameString={text('noNameString', 'Untitled Card')} + placeholder={text('placeholder', 'Name this card...')} + /> +
+ ), + { + readme: { + content: ResourceCardEditableNameReadme, + }, + } +) + +const exampleDashboards = [ + { + id: '23wfsdff', + name: 'Server Stats', + description: 'Monitoring dashboard for our 17 servers', + updatedAt: '24m ago', + createdBy: 'Bob', + }, + { + id: '9sdifsdw', + name: 'West Garden', + description: 'Soil and water monitoring for west side garden', + updatedAt: '8d ago', + createdBy: 'Bob', + }, + { + id: '0sdf09ds', + name: 'East Garden', + description: 'Soil and water monitoring for east side garden', + updatedAt: '2s ago', + createdBy: 'Fred', + }, +] + +indexListExampleStories.add( + 'Dashboards List', + () => ( +
+ + + } + > + + alert( + `Sorter clicked! nextSort: ${nextSort}, sortKey: ${sortKey}` + ) + } + sort={Sort.None} + /> + + alert( + `Sorter clicked! nextSort: ${nextSort}, sortKey: ${sortKey}` + ) + } + sort={Sort.None} + /> + + alert( + `Sorter clicked! nextSort: ${nextSort}, sortKey: ${sortKey}` + ) + } + sort={Sort.None} + /> + + + + + } + > + {object('Dashboards', exampleDashboards) + .filter(d => + d.name.toLocaleLowerCase().includes(text('Search term', '')) + ) + .map(dash => ( + } + description={ + + alert(`onUpate description fired: ${desc}`) + } + /> + } + metaData={[ + <>Last updated {dash.updatedAt}, + <> + Created by {dash.createdBy} + , + ]} + /> + ))} + + +
+ ), + { + readme: { + content: ResourceListExampleReadme, + }, + } +) + +indexListExampleStories.add( + 'Toggleable Card', + () => ( +
+
+ + } + description={ + alert(`onUpate description fired: ${desc}`)} + /> + } + metaData={[ + <>Last updated 2h ago, + <> + Created by Pink Floyd + , + ]} + disabled={boolean('disabled', false)} + toggle={ + {}} + /> + } + contextMenu={ + + } + labels={ + + + } + /> +
+
+ ), + { + readme: { + content: ResourceCardExampleReadme, + }, + } +) diff --git a/src/Components/ResourceList/ResourceListExample.md b/src/Components/ResourceList/ResourceListExample.md new file mode 100644 index 00000000..bb6a3d03 --- /dev/null +++ b/src/Components/ResourceList/ResourceListExample.md @@ -0,0 +1,17 @@ +# Dashboards List + +This example shows off how all the `ResourceList` components can work together. `ResourceListBody` requires and empty state component because it assumes the list will be filterable. Try typing a value into the `Search term` field in the `Knobs` panel to see this in action. + +### Usage +```js +import {ResourceList, ResourceCard, EmptyState} from '@influxdata/clockface' +``` + +### Example + + + + + + + diff --git a/src/Styles/_typography.scss b/src/Styles/_typography.scss index a9f7378a..0df4226a 100644 --- a/src/Styles/_typography.scss +++ b/src/Styles/_typography.scss @@ -15,9 +15,9 @@ body { a:link, a:visited { color: $cf-link-default; - transition: color 0.2s ease; + transition: color 0.25s ease; text-decoration: none; - font-weight: 700; + font-weight: $cf-link-weight; &.link-danger { color: $cf-link-danger; diff --git a/src/Styles/_variables.scss b/src/Styles/_variables.scss index 49af1495..d495950b 100644 --- a/src/Styles/_variables.scss +++ b/src/Styles/_variables.scss @@ -67,6 +67,7 @@ $cf-text-base-4: (ceil($cf-text-base * $cf-text-scale * $cf-text-scale * $cf-tex $cf-text-base-5: (ceil($cf-text-base * $cf-text-scale * $cf-text-scale * $cf-text-scale * $cf-text-scale * $cf-text-scale)); $cf-text-default: $g13-mist; +$cf-link-weight: 700; $cf-link-default: $c-pool; $cf-link-default-hover: $c-laser; $cf-link-success: $c-rainforest; diff --git a/src/index.ts b/src/index.ts index bbc7d672..247a4e06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,4 +71,12 @@ export * from './Components/IndexList/IndexListHeader' export * from './Components/IndexList/IndexListHeaderCell' export * from './Components/IndexList/IndexListRow' export * from './Components/IndexList/IndexListRowCell' +export * from './Components/ResourceList/List/ResourceList' +export * from './Components/ResourceList/List/ResourceListBody' +export * from './Components/ResourceList/List/ResourceListHeader' +export * from './Components/ResourceList/List/ResourceListSorter' +export * from './Components/ResourceList/Card/ResourceCard' +export * from './Components/ResourceList/Card/ResourceCardDescription' +export * from './Components/ResourceList/Card/ResourceCardName' +export * from './Components/ResourceList/Card/ResourceCardEditableName' export * from './Types/index'