Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

gestalt

Package Overview
Dependencies
Maintainers
4
Versions
2756
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

gestalt - npm Package Compare versions

Comparing version 0.39.0 to 0.40.0

src/Collection/__snapshots__/Collection.jest.js.snap

2

package.json
{
"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

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc