Comparing version 0.39.0 to 0.40.0
{ | ||
"name": "gestalt", | ||
"version": "0.39.0", | ||
"version": "0.40.0", | ||
"license": "Apache-2.0", | ||
@@ -5,0 +5,0 @@ "homepage": "https://pinterest.github.io/gestalt", |
/* eslint-env jest */ | ||
import React from 'react'; | ||
import { create } from 'react-test-renderer'; | ||
import { DefaultAvatar } from '../GroupAvatar'; | ||
import GroupAvatar from '../GroupAvatar'; | ||
describe('DefaultAvatar', () => { | ||
it('renders multi-byte character initials', () => { | ||
const avatarData = { | ||
name: '💩 astral', | ||
}; | ||
const tree = create(<DefaultAvatar data={avatarData} />).toJSON(); | ||
const tree = create( | ||
<GroupAvatar collaborators={[{ name: '💩 astral' }]} size="md" /> | ||
).toJSON(); | ||
expect(tree).toMatchSnapshot(); | ||
@@ -17,9 +15,49 @@ }); | ||
it('renders single-byte character initials', () => { | ||
const avatarData = { | ||
name: 'Hello!', | ||
}; | ||
const tree = create( | ||
<GroupAvatar collaborators={[{ name: 'Jane Smith' }]} size="md" /> | ||
).toJSON(); | ||
expect(tree).toMatchSnapshot(); | ||
}); | ||
const tree = create(<DefaultAvatar data={avatarData} />).toJSON(); | ||
it('renders 2 avatars', () => { | ||
const tree = create( | ||
<GroupAvatar | ||
collaborators={[ | ||
{ name: 'Jane Smith', src: 'foo.png' }, | ||
{ name: 'Jane Smith', src: 'foo.png' }, | ||
]} | ||
size="md" | ||
/> | ||
).toJSON(); | ||
expect(tree).toMatchSnapshot(); | ||
}); | ||
it('renders 3 avatars', () => { | ||
const tree = create( | ||
<GroupAvatar | ||
collaborators={[ | ||
{ name: 'Jane Smith', src: 'foo.png' }, | ||
{ name: 'Jane Smith', src: 'foo.png' }, | ||
{ name: 'Jane Smith', src: 'foo.png' }, | ||
]} | ||
size="md" | ||
/> | ||
).toJSON(); | ||
expect(tree).toMatchSnapshot(); | ||
}); | ||
it('renders more than 3 AVATAR_SIZES', () => { | ||
const tree = create( | ||
<GroupAvatar | ||
collaborators={[ | ||
{ name: 'Jane Smith', src: 'foo.png' }, | ||
{ name: 'Jane Smith', src: 'foo.png' }, | ||
{ name: 'Jane Smith', src: 'foo.png' }, | ||
{ name: 'Jane Smith', src: 'foo.png' }, | ||
]} | ||
size="md" | ||
/> | ||
).toJSON(); | ||
expect(tree).toMatchSnapshot(); | ||
}); | ||
}); |
// @flow | ||
import React from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import Masonry from '../Masonry/Masonry'; | ||
import styles from './GroupAvatar.css'; | ||
import Box from '../Box/Box'; | ||
import Text from '../Text/Text'; | ||
import Image from '../Image/Image'; | ||
import Collection from '../Collection/Collection'; | ||
const GUTTER_WIDTH = 2; | ||
const BORDER_WIDTH = 2; | ||
@@ -27,193 +28,181 @@ const AVATAR_SIZES = { | ||
const PLACEMENT = { | ||
full: 'FULL', | ||
half: 'HALF', | ||
quarter: 'QUARTER', | ||
}; | ||
type CollabProps = { | ||
name: string, | ||
src?: string, | ||
}; | ||
type ModifiedAvatarProps = CollabProps & { | ||
numCollabs: number, | ||
placement: 'FULL' | 'HALF' | 'QUARTER', | ||
type Props = {| | ||
collaborators: Array<{| | ||
name: string, | ||
src?: string, | ||
|}>, | ||
size: 'xs' | 'sm' | 'md' | 'lg' | 'xl', | ||
}; | ||
type GroupAvatarProps = {| | ||
collaborators: Array<CollabProps>, | ||
size: 'xs' | 'sm' | 'md' | 'lg' | 'xl', | ||
|}; | ||
type GridItemPropsType = { | ||
data: ModifiedAvatarProps, | ||
const avatarLayout = (n, size) => { | ||
switch (n) { | ||
case 1: | ||
return [{ top: 0, left: 0, width: size, height: size }]; | ||
case 2: | ||
return [ | ||
{ | ||
top: 0, | ||
left: 0, | ||
width: size / 2 - BORDER_WIDTH / 2, | ||
height: size, | ||
}, | ||
{ | ||
top: 0, | ||
left: size / 2 + BORDER_WIDTH / 2, | ||
width: size / 2 - BORDER_WIDTH / 2, | ||
height: size, | ||
}, | ||
]; | ||
default: | ||
return [ | ||
{ | ||
top: 0, | ||
left: 0, | ||
width: size / 2 - BORDER_WIDTH / 2, | ||
height: size, | ||
}, | ||
{ | ||
top: 0, | ||
left: size / 2 + BORDER_WIDTH / 2, | ||
width: size / 2, | ||
height: size / 2 - BORDER_WIDTH / 2, | ||
}, | ||
{ | ||
top: size / 2 + BORDER_WIDTH / 2, | ||
left: size / 2 + BORDER_WIDTH / 2, | ||
width: size / 2, | ||
height: size / 2 - BORDER_WIDTH / 2, | ||
}, | ||
]; | ||
} | ||
}; | ||
type DefaultAvatarProps = { | ||
data: ModifiedAvatarProps, | ||
const degToRad = deg => deg * (Math.PI / 180); | ||
const DefaultAvatar = (props: { | ||
height: number, | ||
}; | ||
name: string, | ||
textLayout: 'center' | 'topLeft' | 'bottomLeft', | ||
size: 'xs' | 'sm' | 'md' | 'lg' | 'xl', | ||
}) => { | ||
const { height, name, textLayout } = props; | ||
const size = AVATAR_SIZES[props.size]; | ||
const fontSize = DEFAULT_AVATAR_TEXT_SIZES[props.size] / 2; | ||
function DefaultAvatar(props: DefaultAvatarProps) { | ||
const { data, height } = props; | ||
const fontSize = DEFAULT_AVATAR_TEXT_SIZES[data.size] / 2; | ||
const firstInitial = [...data.name][0].toUpperCase(); | ||
const additionalStyles = | ||
data.placement === 'QUARTER' | ||
? { | ||
marginLeft: '15%', | ||
textAlign: 'none', | ||
} | ||
: { | ||
marginLeft: '0', | ||
textAlign: 'center', | ||
}; | ||
const initialStyles = { | ||
...additionalStyles, | ||
fontSize, | ||
lineHeight: `${height}px`, | ||
}; | ||
return ( | ||
<Box | ||
aria-label={data.name} | ||
color="gray" | ||
height={height} | ||
width={data.placement === 'HALF' ? height / 2 : height} | ||
overflow="hidden" | ||
position="relative" | ||
> | ||
<Text bold color="white"> | ||
<Box dangerouslySetInlineStyle={{ __style: initialStyles }}> | ||
{firstInitial} | ||
</Box> | ||
</Text> | ||
</Box> | ||
const quarterPadding = Math.floor( | ||
(size / 2 - fontSize) / 2 * Math.sin(degToRad(45)) | ||
); | ||
} | ||
const getAvatarStyles = (placement: string, size: string) => { | ||
/* adjusted dimensions for avatar's only displaying in a quarter of the circle */ | ||
const dimensions = | ||
placement === 'QUARTER' | ||
? (AVATAR_SIZES[size] - GUTTER_WIDTH) / 2 | ||
: AVATAR_SIZES[size]; | ||
const visibleWidth = (dimensions - GUTTER_WIDTH) / 2; | ||
/* adjusted left offset for avatar's taking up half the circle to center image */ | ||
const left = placement === 'HALF' ? (visibleWidth - dimensions) / 2 : 0; | ||
return { | ||
height: 'auto', | ||
left, | ||
/* Fixes issue where parent element using GroupAvatar has maxWidth: 100% set and images | ||
no longer expand to fill space properly */ | ||
maxWidth: 'none', | ||
position: 'relative', | ||
width: dimensions, | ||
}; | ||
}; | ||
/* Adds numCollabs and size to each piece of data in the grid | ||
in order to have access to these attributes later on */ | ||
const addPositionDataToCollabs = ( | ||
collaborators: Array<CollabProps>, | ||
size: string | ||
) => { | ||
const numCollabs = collaborators.length; | ||
return collaborators.map((collab, i) => { | ||
let placement; | ||
if (numCollabs === 1) { | ||
placement = PLACEMENT.full; | ||
} else if (i === 0 || numCollabs === 2) { | ||
placement = PLACEMENT.half; | ||
} else { | ||
placement = PLACEMENT.quarter; | ||
} | ||
return { | ||
...collab, | ||
placement, | ||
size, | ||
}; | ||
}); | ||
}; | ||
/* Avatar component to display data in grid */ | ||
function Avatar(props: GridItemPropsType) { | ||
const { data } = props; | ||
const avatarStyles = getAvatarStyles(data.placement, data.size); | ||
const backgroundColor = data.src ? 'lightGray' : 'gray'; | ||
const avatarSection = data.src ? ( | ||
<div> | ||
<div className={styles.wash} /> | ||
<img | ||
alt={data.name} | ||
height={1} | ||
src={data.src} | ||
style={avatarStyles} | ||
width={1} | ||
/> | ||
</div> | ||
) : ( | ||
<DefaultAvatar | ||
data={data} | ||
height={avatarStyles.width} | ||
placement={data.placement} | ||
/> | ||
); | ||
return ( | ||
<Box | ||
height={avatarStyles.width + GUTTER_WIDTH} | ||
dangerouslySetInlineStyle={{ __style: { margin: GUTTER_WIDTH / 2 } }} | ||
overflow="hidden" | ||
position="relative" | ||
> | ||
const initial = ( | ||
<Text bold color="white"> | ||
<Box | ||
color={backgroundColor} | ||
height={avatarStyles.width} | ||
overflow="hidden" | ||
dangerouslySetInlineStyle={{ | ||
__style: { | ||
fontSize, | ||
lineHeight: `${fontSize}px`, | ||
}, | ||
}} | ||
> | ||
{avatarSection} | ||
{[...name][0].toUpperCase()} | ||
</Box> | ||
</Box> | ||
</Text> | ||
); | ||
} | ||
export { Avatar, DefaultAvatar }; | ||
switch (textLayout) { | ||
case 'bottomLeft': | ||
return ( | ||
<Box | ||
aria-label={name} | ||
color="gray" | ||
height={height} | ||
display="flex" | ||
alignItems="end" | ||
dangerouslySetInlineStyle={{ | ||
__style: { | ||
padding: quarterPadding, | ||
}, | ||
}} | ||
> | ||
{initial} | ||
</Box> | ||
); | ||
case 'topLeft': | ||
return ( | ||
<Box | ||
aria-label={name} | ||
color="gray" | ||
height={height} | ||
display="flex" | ||
alignItems="start" | ||
dangerouslySetInlineStyle={{ | ||
__style: { | ||
padding: quarterPadding, | ||
}, | ||
}} | ||
> | ||
{initial} | ||
</Box> | ||
); | ||
default: | ||
return ( | ||
<Box | ||
aria-label={name} | ||
color="gray" | ||
height={height} | ||
display="flex" | ||
alignItems="center" | ||
justifyContent="center" | ||
> | ||
{initial} | ||
</Box> | ||
); | ||
} | ||
}; | ||
export default function GroupAvatar(props: GroupAvatarProps) { | ||
export default function GroupAvatar(props: Props) { | ||
const { collaborators, size } = props; | ||
const collabs = addPositionDataToCollabs(collaborators, size).slice(0, 3); | ||
const MAX_AVATAR_DIM = AVATAR_SIZES[size]; | ||
const HALF_AVATAR_DIM = (MAX_AVATAR_DIM - GUTTER_WIDTH) / 2; | ||
const borderBoxStyle = { | ||
border: '2px solid #ffffff', | ||
boxSizing: 'content-box', | ||
willChange: 'transform', | ||
}; | ||
const layout = avatarLayout(collaborators.length, AVATAR_SIZES[size] - 4); | ||
return ( | ||
<Box | ||
color="white" | ||
height={MAX_AVATAR_DIM} | ||
width={MAX_AVATAR_DIM} | ||
dangerouslySetInlineStyle={{ __style: borderBoxStyle }} | ||
overflow="hidden" | ||
shape="circle" | ||
dangerouslySetInlineStyle={{ __style: { border: '2px solid #FFF' } }} | ||
> | ||
<Box | ||
dangerouslySetInlineStyle={{ __style: { margin: GUTTER_WIDTH / -2 } }} | ||
> | ||
<Masonry | ||
comp={Avatar} | ||
flexible | ||
items={collabs} | ||
minCols={1} | ||
columnWidth={collabs.length === 1 ? MAX_AVATAR_DIM : HALF_AVATAR_DIM} | ||
/> | ||
</Box> | ||
<Collection | ||
layout={layout} | ||
Item={({ idx }) => { | ||
const { name, src } = collaborators[idx]; | ||
const { width, height } = layout[idx]; | ||
if (!src) { | ||
return ( | ||
<DefaultAvatar | ||
name={name} | ||
textLayout={ | ||
collaborators.length >= 3 ? ( | ||
['center', 'bottomLeft', 'topLeft'][idx] | ||
) : ( | ||
'center' | ||
) | ||
} | ||
height={height} | ||
size={size} | ||
/> | ||
); | ||
} | ||
return ( | ||
<Box position="relative" width={width} height={height}> | ||
<Image | ||
alt={name} | ||
color="#EFEFEF" | ||
src={src} | ||
naturalWidth={1} | ||
naturalHeight={1} | ||
fit="cover" | ||
/> | ||
<div className={styles.wash} /> | ||
</Box> | ||
); | ||
}} | ||
/> | ||
</Box> | ||
@@ -223,8 +212,2 @@ ); | ||
/* | ||
Temporarily disable unused-props rule until the following issues are addressed: | ||
https://github.com/yannickcr/eslint-plugin-react/issues/819 | ||
https://github.com/yannickcr/eslint-plugin-react/issues/861 | ||
*/ | ||
/* eslint-disable react/no-unused-prop-types */ | ||
GroupAvatar.propTypes = { | ||
@@ -239,2 +222,1 @@ collaborators: PropTypes.arrayOf( | ||
}; | ||
/* eslint-enable react/no-unused-prop-types */ |
@@ -38,3 +38,3 @@ // @flow | ||
measurementStore?: MeasurementStore<T>, | ||
minCols?: number, | ||
minCols: number, | ||
layout: ({ | ||
@@ -83,8 +83,83 @@ measurementStore: MeasurementStore<T>, | ||
class Masonry<T> extends Component<any, Props<T>, any> { | ||
const gutter = (gutterWidth, flexible) => { | ||
// Default to 0 gutterWidth when rendering flexibly. | ||
if (gutterWidth == null) { | ||
return flexible ? 0 : 14; | ||
} | ||
return gutterWidth; | ||
}; | ||
export default class Masonry<T> extends Component<any, Props<T>, any> { | ||
static createMeasurementStore() { | ||
return new MeasurementStore(); | ||
} | ||
static defaultProps: {}; | ||
static propTypes = { | ||
/** | ||
* The preferred/target item width. If `flexible` is set, the item width will | ||
* grow to fill column space, and shrink to fit if below min columns. | ||
*/ | ||
columnWidth: PropTypes.number, | ||
/** | ||
* The component to render. | ||
*/ | ||
/* eslint react/no-unused-prop-types: 0 */ | ||
comp: PropTypes.func.isRequired, | ||
/** | ||
* The preferred/target item width. Item width will grow to fill | ||
* column space, and shrink to fit if below min columns. | ||
*/ | ||
flexible: PropTypes.bool, | ||
/** | ||
* The amount of space between each item. | ||
*/ | ||
gutterWidth: PropTypes.number, | ||
/** | ||
* An array of all objects to display in the grid. | ||
*/ | ||
items: PropTypes.arrayOf(PropTypes.shape({})).isRequired, | ||
/** | ||
* A callback which the grid calls when we need to load more items as the user scrolls. | ||
* The callback should update the state of the items, and pass those in as props | ||
* to this component. | ||
*/ | ||
loadItems: PropTypes.func, | ||
/** | ||
* Minimum number of columns to display. | ||
*/ | ||
minCols: PropTypes.number.isRequired, | ||
/** | ||
* Function that the grid calls to get the scroll container. | ||
* This is required if the grid is expected to be scrollable. | ||
*/ | ||
scrollContainer: PropTypes.func, | ||
/** | ||
* Whether or not this instance is server rendered. | ||
* TODO: If true, generate and output CSS for the initial server render. | ||
*/ | ||
serverRender: PropTypes.bool, | ||
/** | ||
* Whether or not to use actual virtualization | ||
*/ | ||
virtualize: PropTypes.bool, | ||
}; | ||
static defaultProps = { | ||
columnWidth: 236, | ||
minCols: 3, | ||
serverRender: false, | ||
layout: MasonryLayout, | ||
loadItems: () => {}, | ||
virtualize: false, | ||
}; | ||
constructor(props: Props<*>) { | ||
@@ -99,13 +174,6 @@ super(props); | ||
// Default to 0 gutterWidth when rendering flexibly. | ||
if (props.flexible && props.gutterWidth === null) { | ||
this.gutterWidth = 0; | ||
} else if (props.gutterWidth === null) { | ||
this.gutterWidth = 14; | ||
} else if (typeof props.gutterWidth === 'number') { | ||
this.gutterWidth = props.gutterWidth; | ||
} | ||
// initialize here for server rendering | ||
this.itemWidth = props.flexible ? 0 : props.columnWidth + this.gutterWidth; | ||
this.itemWidth = props.flexible | ||
? 0 | ||
: props.columnWidth + gutter(props.gutterWidth, props.flexible); | ||
@@ -136,6 +204,23 @@ // measurement store | ||
let serverItems = null; | ||
if (props.serverRender) { | ||
serverItems = props.items.map((itemData, key) => { | ||
if (this.measurementStore.hasItemMeasurement(itemData)) { | ||
// If we already have the item's measurement, then we can assume this isn't | ||
// a server item, but most likely the grid being re-mounted, so defer positioning | ||
// to insertItems. | ||
return {}; | ||
} | ||
return { | ||
itemData, | ||
slotIdx: key + 1, | ||
position: { top: 0, left: 0 }, | ||
}; | ||
}); | ||
} | ||
this.state = { | ||
isFetching: false, | ||
gridItems: [], | ||
serverItems: props.serverRender ? this.serverItems(props.items) : null, | ||
serverItems, | ||
mounted: false, | ||
@@ -146,3 +231,3 @@ scrollTop: 0, | ||
const layoutConfig = { | ||
gutterWidth: this.gutterWidth, | ||
gutterWidth: gutter(props.gutterWidth, props.flexible), | ||
measurementStore: this.measurementStore, | ||
@@ -177,5 +262,24 @@ }; | ||
this.updateDimensions(); | ||
this.measureServerRefSizes(); | ||
this.updateItems(this.props.items); | ||
// Measure server ref sizes | ||
if (this.serverRefs.length > 0) { | ||
this.serverRefs.forEach(({ itemData, ref }) => { | ||
if (this.measurementStore.hasItemMeasurement(itemData)) { | ||
return; | ||
} | ||
const serverRendered = ref; | ||
serverRendered.style.width = `${this.itemWidth - | ||
gutter(this.props.gutterWidth, this.props.flexible)}px`; | ||
const height = serverRendered.clientHeight; | ||
this.measurementStore.setItemMeasurement(itemData, { height }); | ||
}); | ||
} | ||
if ( | ||
this.props.items && | ||
this.props.items.length !== this.insertedItemsCount | ||
) { | ||
this.insertItems(this.props.items.slice(this.insertedItemsCount)); | ||
} | ||
setTimeout(() => { | ||
@@ -197,3 +301,6 @@ this.measureContainer(); | ||
if (this.props.items[i] === undefined) { | ||
this.updateItems(items); | ||
if (items && items.length !== this.insertedItemsCount) { | ||
this.insertItems(items.slice(this.insertedItemsCount)); | ||
} | ||
this.setState({ isFetching: false }); | ||
@@ -214,3 +321,4 @@ return; | ||
this.cleanupMeasuringComponents(); | ||
this.setGridItems(items); | ||
this.insertedItemsCount = 0; | ||
this.insertItems(items, null, null, true); | ||
this.setState({ isFetching: false }); | ||
@@ -333,23 +441,4 @@ return; | ||
getVirtualBounds() { | ||
const virtualBuffer = this.containerHeight * VIRTUAL_BUFFER_FACTOR; | ||
const offsetScrollPos = this.state.scrollTop - this.containerOffset; | ||
return { | ||
viewportTop: offsetScrollPos - virtualBuffer, | ||
viewportBottom: offsetScrollPos + this.containerHeight + virtualBuffer, | ||
}; | ||
} | ||
/** | ||
* Sets all grid items in the grid. | ||
*/ | ||
setGridItems(items: Array<*>) { | ||
this.insertedItemsCount = 0; | ||
this.insertItems(items, null, null, true); | ||
} | ||
props: Props<*>; | ||
columnCount: number; | ||
gutterWidth: number; | ||
itemWidth: number; | ||
@@ -381,3 +470,3 @@ layout: LayoutType; | ||
const width = this.props.columnWidth; | ||
const gutterWidth = this.gutterWidth; | ||
const gutterWidth = gutter(this.props.gutterWidth, this.props.flexible); | ||
@@ -421,11 +510,2 @@ if (columnA === columnB) { | ||
updateItems(items: Array<*>) { | ||
if (!items) { | ||
return; | ||
} | ||
if (items.length !== this.insertedItemsCount) { | ||
this.insertItems(items.slice(this.insertedItemsCount)); | ||
} | ||
} | ||
handleAddRelatedItems(itemData: T) { | ||
@@ -441,21 +521,2 @@ return (items: Array<GridItemType<T>>) => { | ||
serverItems(items: Array<*>) { | ||
const serverItems = items.map((itemData, key) => { | ||
if (this.measurementStore.hasItemMeasurement(itemData)) { | ||
// If we already have the item's measurement, then we can assume this isn't | ||
// a server item, but most likely the grid being re-mounted, so defer positioning | ||
// to insertItems. | ||
return {}; | ||
} | ||
const slotIdx = key + 1; | ||
return { | ||
itemData, | ||
slotIdx, | ||
position: { top: 0, left: 0 }, | ||
}; | ||
}); | ||
return serverItems; | ||
} | ||
measureItems(pendingDomMeasurements: Array<*>) { | ||
@@ -474,3 +535,4 @@ if (!pendingDomMeasurements.length) { | ||
visibility: 'hidden', | ||
width: `${this.itemWidth - this.gutterWidth}px`, | ||
width: `${this.itemWidth - | ||
gutter(this.props.gutterWidth, this.props.flexible)}px`, | ||
}} | ||
@@ -482,2 +544,3 @@ > | ||
itemIdx={slotIdx} | ||
isMeasuring | ||
/> | ||
@@ -519,3 +582,4 @@ </div> | ||
const left = column * this.itemWidth; | ||
const bottom = top + itemHeight + this.gutterWidth; | ||
const bottom = | ||
top + itemHeight + gutter(this.props.gutterWidth, this.props.flexible); | ||
@@ -528,17 +592,2 @@ const newPosition = { column, bottom, left, top, row: itemIdx }; | ||
measureServerRefSizes() { | ||
if (this.serverRefs.length <= 0) { | ||
return; | ||
} | ||
this.serverRefs.forEach(({ itemData, ref }) => { | ||
if (this.measurementStore.hasItemMeasurement(itemData)) { | ||
return; | ||
} | ||
const serverRendered = ref; | ||
serverRendered.style.width = `${this.itemWidth - this.gutterWidth}px`; | ||
const height = serverRendered.clientHeight; | ||
this.measurementStore.setItemMeasurement(itemData, { height }); | ||
}); | ||
} | ||
insertItems( | ||
@@ -679,6 +728,15 @@ newItems: Array<*>, | ||
const gridWidth = el.parentNode.clientWidth; | ||
this.columnCount = this.calculateColumns(); | ||
// Calculate the number of columns | ||
const eachItemWidth = | ||
this.props.columnWidth + | ||
gutter(this.props.gutterWidth, this.props.flexible); | ||
this.columnCount = Math.max( | ||
this.props.minCols, | ||
Math.floor(gridWidth / eachItemWidth) | ||
); | ||
this.itemWidth = flexible | ||
? Math.floor(gridWidth / this.columnCount) | ||
: columnWidth + this.gutterWidth; | ||
: columnWidth + gutter(this.props.gutterWidth, flexible); | ||
this.measurementStore.setDimensions({ | ||
@@ -702,24 +760,2 @@ columnCount: this.columnCount, | ||
/** | ||
* Determines the number of columns to display. | ||
*/ | ||
calculateColumns() { | ||
const eachItemWidth = this.props.columnWidth + this.gutterWidth; | ||
const el = this.gridWrapper; | ||
if (el && el.parentNode instanceof HTMLElement) { | ||
const parentWidth = el.parentNode.clientWidth; | ||
let newColCount = Math.floor(parentWidth / eachItemWidth); | ||
if ( | ||
typeof this.props.minCols === 'number' && | ||
newColCount < this.props.minCols | ||
) { | ||
newColCount = this.props.minCols; | ||
} | ||
return newColCount; | ||
} | ||
throw new Error('could not calculate columns'); | ||
} | ||
measureContainer() { | ||
@@ -750,12 +786,6 @@ if (!this.scrollContainer) { | ||
this.measureContainer(); | ||
this.setGridItems(this.state.gridItems); | ||
this.insertedItemsCount = 0; | ||
this.insertItems(this.state.gridItems, null, null, true); | ||
} | ||
/** | ||
* # of columns * total item width - 1 item margin | ||
*/ | ||
determineWidth() { | ||
return `${this.columnCount * this.itemWidth - this.gutterWidth}px`; | ||
} | ||
fetchMore = () => { | ||
@@ -774,17 +804,2 @@ const { loadItems } = this.props; | ||
itemIsVisible(itemPosition: ItemPositionType) { | ||
if (!this.props.scrollContainer) { | ||
// if no scroll container is passed in, items should always be visible | ||
return true; | ||
} | ||
const { viewportTop, viewportBottom } = this.getVirtualBounds(); | ||
const isVisible = !( | ||
itemPosition.bottom < viewportTop || itemPosition.top > viewportBottom | ||
); | ||
return isVisible; | ||
} | ||
renderHeight = () => | ||
this.measurementStore.getColumnHeight(this.measurementStore.shortestColumn); | ||
renderMasonryComponent = ( | ||
@@ -802,4 +817,19 @@ { appended, itemData, position, slotIdx }: GridItemType<*>, | ||
const { top, left } = itemPosition; | ||
const isVisible = this.itemIsVisible(itemPosition); | ||
let isVisible; | ||
if (this.props.scrollContainer) { | ||
const virtualBuffer = this.containerHeight * VIRTUAL_BUFFER_FACTOR; | ||
const offsetScrollPos = this.state.scrollTop - this.containerOffset; | ||
const viewportTop = offsetScrollPos - virtualBuffer; | ||
const viewportBottom = | ||
offsetScrollPos + this.containerHeight + virtualBuffer; | ||
isVisible = !( | ||
itemPosition.bottom < viewportTop || itemPosition.top > viewportBottom | ||
); | ||
} else { | ||
// if no scroll container is passed in, items should always be visible | ||
isVisible = true; | ||
} | ||
const itemClassName = [ | ||
@@ -821,3 +851,7 @@ this.state.serverItems ? 'static' : styles.Masonry__Item, | ||
...(this.itemWidth | ||
? { width: this.itemWidth - this.gutterWidth } | ||
? { | ||
width: | ||
this.itemWidth - | ||
gutter(this.props.gutterWidth, this.props.flexible), | ||
} | ||
: {}), | ||
@@ -877,3 +911,7 @@ ...(virtualize || isVisible | ||
ref={this.setGridWrapperRef} | ||
style={{ height, width: this.determineWidth() }} | ||
style={{ | ||
height, | ||
width: `${this.columnCount * this.itemWidth - | ||
gutter(this.props.gutterWidth, this.props.flexible)}px`, | ||
}} | ||
> | ||
@@ -888,3 +926,5 @@ {this.scrollContainer && ( | ||
} | ||
scrollHeight={this.renderHeight()} | ||
scrollHeight={this.measurementStore.getColumnHeight( | ||
this.measurementStore.shortestColumn | ||
)} | ||
scrollTop={this.state.scrollTop} | ||
@@ -909,72 +949,1 @@ /> | ||
} | ||
Masonry.propTypes = { | ||
/** | ||
* The preferred/target item width. If `flexible` is set, the item width will | ||
* grow to fill column space, and shrink to fit if below min columns. | ||
*/ | ||
columnWidth: PropTypes.number, | ||
/** | ||
* The component to render. | ||
*/ | ||
/* eslint react/no-unused-prop-types: 0 */ | ||
comp: PropTypes.func.isRequired, | ||
/** | ||
* The preferred/target item width. Item width will grow to fill | ||
* column space, and shrink to fit if below min columns. | ||
*/ | ||
flexible: PropTypes.bool, | ||
/** | ||
* The amount of space between each item. | ||
*/ | ||
gutterWidth: PropTypes.number, | ||
/** | ||
* An array of all objects to display in the grid. | ||
*/ | ||
items: PropTypes.arrayOf(PropTypes.shape({})).isRequired, | ||
/** | ||
* A callback which the grid calls when we need to load more items as the user scrolls. | ||
* The callback should update the state of the items, and pass those in as props | ||
* to this component. | ||
*/ | ||
loadItems: PropTypes.func, | ||
/** | ||
* Minimum number of columns to display. | ||
*/ | ||
minCols: PropTypes.number, | ||
/** | ||
* Function that the grid calls to get the scroll container. | ||
* This is required if the grid is expected to be scrollable. | ||
*/ | ||
scrollContainer: PropTypes.func, | ||
/** | ||
* Whether or not this instance is server rendered. | ||
* TODO: If true, generate and output CSS for the initial server render. | ||
*/ | ||
serverRender: PropTypes.bool, | ||
/** | ||
* Whether or not to use actual virtualization | ||
*/ | ||
virtualize: PropTypes.bool, | ||
}; | ||
Masonry.defaultProps = { | ||
columnWidth: 236, | ||
gutterWidth: null, | ||
minCols: 3, | ||
serverRender: false, | ||
layout: MasonryLayout, | ||
loadItems: () => {}, | ||
virtualize: false, | ||
}; | ||
export default Masonry; |
@@ -18,2 +18,6 @@ // @flow | ||
{ | ||
name: 'errorMessage', | ||
type: '?string', | ||
}, | ||
{ | ||
name: 'id', | ||
@@ -24,4 +28,10 @@ type: 'string', | ||
{ | ||
name: 'idealErrorDirection', | ||
type: `?'up' | 'right' | 'down' | 'left'`, | ||
description: 'Preferred direction for the ErrorFlyout to open', | ||
defaultValue: 'right', | ||
}, | ||
{ | ||
name: 'name', | ||
type: 'string', | ||
type: '?string', | ||
}, | ||
@@ -39,2 +49,6 @@ { | ||
{ | ||
name: 'placeholder', | ||
type: '?string', | ||
}, | ||
{ | ||
name: 'value', | ||
@@ -49,3 +63,24 @@ type: '?string', | ||
const options = [ | ||
card( | ||
'FlowTypes', | ||
md` | ||
\`\`\`jsx | ||
type Props = { | ||
errorMessage?: string, | ||
id: string, | ||
idealErrorDirection?: 'up' | 'right' | 'down' | 'left', /* default: right */ | ||
name?: string, | ||
onChange: (value: string) => void, | ||
options: Array<{ | ||
label: string, | ||
value: string, | ||
}>, | ||
placeholder?: string, | ||
value?: string, | ||
}; | ||
\`\`\` | ||
` | ||
); | ||
const countryOptions = [ | ||
{ | ||
@@ -69,2 +104,17 @@ value: 'aus', | ||
const cityOptions = [ | ||
{ | ||
value: 'bos', | ||
label: 'Boston', | ||
}, | ||
{ | ||
value: 'la', | ||
label: 'Los Angeles', | ||
}, | ||
{ | ||
value: 'sf', | ||
label: 'San Francisco', | ||
}, | ||
]; | ||
card( | ||
@@ -85,3 +135,4 @@ 'Example', | ||
onChange={({ value }) => this.setState({ value })} | ||
options={options} | ||
options={countryOptions} | ||
placeholder="Select country" | ||
value={this.state.value} | ||
@@ -103,3 +154,4 @@ /> | ||
onChange={({ value }) => atom.reset({ value })} | ||
options={options} | ||
options={countryOptions} | ||
placeholder="Select country" | ||
value={atom.deref().value} | ||
@@ -110,1 +162,46 @@ /> | ||
); | ||
card( | ||
'Errors', | ||
md`SelectList's can display their own error messages if you'd like them to. | ||
To use our errors, simply pass in an \`errorMessage\` when there is an error present and we will | ||
handle the rest. | ||
\`\`\`jsx | ||
<Box> | ||
<Box paddingY={2}> | ||
<Label htmlFor="city"> | ||
<Text>City</Text> | ||
</Label> | ||
</Box> | ||
<SelectList | ||
id="city" | ||
errorMessage="This field can't be blank!" | ||
name="city" | ||
onChange={({ value }) => this.setState({ value })} | ||
options={cityOptions} | ||
placeholder="Select city" | ||
value={this.state.value} | ||
/> | ||
</Box> | ||
\`\`\` | ||
`, | ||
atom => ( | ||
<Box> | ||
<Box paddingY={2}> | ||
<Label htmlFor="city"> | ||
<Text>City</Text> | ||
</Label> | ||
</Box> | ||
<SelectList | ||
id="city" | ||
errorMessage="This field can't be blank!" | ||
name="city" | ||
onChange={({ value }) => atom.reset({ value })} | ||
options={cityOptions} | ||
placeholder="Select city" | ||
value={atom.deref().value} | ||
/> | ||
</Box> | ||
) | ||
); |
@@ -5,8 +5,32 @@ // @flow | ||
import Box from '../Box/Box'; | ||
import classnames from 'classnames'; | ||
import ErrorFlyout from '../ErrorFlyout/ErrorFlyout'; | ||
import Icon from '../Icon/Icon'; | ||
import styles from './SelectList.css'; | ||
type State = { | ||
focused: boolean, | ||
hovered: boolean, | ||
errorIsOpen: boolean, | ||
}; | ||
type Props = {| | ||
errorMessage?: string, | ||
id: string, | ||
idealErrorDirection?: 'up' | 'right' | 'down' | 'left' /* default: right */, | ||
name?: string, | ||
onChange: (event: { +value: string }) => void, | ||
options: Array<{ | ||
label: string, | ||
value: string, | ||
}>, | ||
placeholder?: string, | ||
value?: ?string, | ||
|}; | ||
export default class SelectList extends Component { | ||
static propTypes = { | ||
errorMessage: PropTypes.string, | ||
id: PropTypes.string.isRequired, | ||
idealErrorDirection: PropTypes.string, | ||
name: PropTypes.string, | ||
@@ -20,2 +44,3 @@ onChange: PropTypes.func.isRequired, | ||
).isRequired, | ||
placeholder: PropTypes.string, | ||
value: PropTypes.string, | ||
@@ -25,20 +50,21 @@ }; | ||
static defaultProps = { | ||
idealErrorDirection: 'right', | ||
options: [], | ||
}; | ||
state = { | ||
state: State = { | ||
focused: false, | ||
hovered: false, | ||
errorIsOpen: false, | ||
}; | ||
props: {| | ||
id: string, | ||
name?: string, | ||
onChange: (event: { +value: string }) => void, | ||
options: Array<{ | ||
label: string, | ||
value: string, | ||
}>, | ||
value?: ?string, | ||
|}; | ||
componentWillReceiveProps(nextProps: Props) { | ||
if (nextProps.errorMessage !== this.props.errorMessage) { | ||
this.setState({ errorIsOpen: !!nextProps.errorMessage }); | ||
} | ||
} | ||
props: Props; | ||
select: HTMLElement; | ||
handleOnChange = (event: SyntheticEvent) => { | ||
@@ -50,11 +76,45 @@ if ( | ||
this.props.onChange({ value: event.target.value }); | ||
if (this.props.errorMessage) { | ||
this.setState({ errorIsOpen: false }); | ||
} | ||
} | ||
}; | ||
handleMouseEnter = () => this.setState({ hovered: true }); | ||
handleMouseLeave = () => this.setState({ hovered: false }); | ||
handleBlur = () => { | ||
if (this.props.errorMessage) { | ||
this.setState({ errorIsOpen: false }); | ||
} | ||
}; | ||
handleFocus = () => { | ||
if (this.props.errorMessage) { | ||
this.setState({ errorIsOpen: true }); | ||
} | ||
}; | ||
handleMouseEnter = () => { | ||
this.setState({ hovered: true }); | ||
}; | ||
handleMouseLeave = () => { | ||
this.setState({ hovered: false }); | ||
}; | ||
render() { | ||
const { id, name, options, value } = this.props; | ||
const { | ||
errorMessage, | ||
id, | ||
idealErrorDirection, | ||
name, | ||
options, | ||
placeholder, | ||
value, | ||
} = this.props; | ||
const classes = classnames(styles.select, { | ||
[styles.normal]: !errorMessage, | ||
[styles.errored]: errorMessage, | ||
}); | ||
return ( | ||
@@ -86,11 +146,25 @@ <Box | ||
<select | ||
className={styles.select} | ||
aria-describedby={ | ||
errorMessage && this.state.focused ? `${id}-gestalt-error` : null | ||
} | ||
aria-invalid={errorMessage ? 'true' : 'false'} | ||
className={classes} | ||
id={id} | ||
name={name} | ||
onBlur={this.handleOnChange} | ||
onBlur={this.handleBlur} | ||
onFocus={this.handleFocus} | ||
onChange={this.handleOnChange} | ||
onMouseEnter={this.handleMouseEnter} | ||
onMouseLeave={this.handleMouseLeave} | ||
ref={c => { | ||
this.select = c; | ||
}} | ||
value={value} | ||
> | ||
{placeholder && | ||
!value && ( | ||
<option selected disabled value hidden> | ||
{placeholder} | ||
</option> | ||
)} | ||
{options.map(option => ( | ||
@@ -102,2 +176,13 @@ <option key={option.value} value={option.value}> | ||
</select> | ||
{errorMessage && | ||
this.state.errorIsOpen && ( | ||
<ErrorFlyout | ||
anchor={this.select} | ||
id={`${id}-gestalt-error`} | ||
idealDirection={idealErrorDirection} | ||
message={errorMessage} | ||
onDismiss={() => this.setState({ errorIsOpen: false })} | ||
size="sm" | ||
/> | ||
)} | ||
</Box> | ||
@@ -104,0 +189,0 @@ ); |
@@ -107,6 +107,6 @@ // @flow | ||
const classes = classnames(styles.textField, { | ||
[styles.normal]: !errorMessage, | ||
[styles.errored]: hasError || errorMessage, | ||
}); | ||
const classes = classnames( | ||
styles.textField, | ||
hasError || errorMessage ? styles.errored : styles.normal | ||
); | ||
@@ -113,0 +113,0 @@ return ( |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
2037786
334
35213