(initState);
const setStateCallback = useCallback((newState: T): void => setState(newState), []);
return [state, setStateCallback];
};
export default useCallbackState;
================================================
FILE: __stories__/helpers/index.ts
================================================
export * from './utils';
export * from './hooks';
export * from './styled';
export * from './constants';
export * from './components';
================================================
FILE: __stories__/helpers/styled/index.ts
================================================
import styled, { css, keyframes } from 'styled-components';
export const MEDIA_QUERY_IS_MOBILE = '@media only screen and (max-width: 768px)';
export const MEDIA_QUERY_IS_MOBILE_XS = '@media only screen and (max-width: 525px)';
export const MEDIA_QUERY_IS_TABLET_OR_DESKTOP = '@media only screen and (min-width: 992px)';
export const MEDIA_QUERY_IS_TABLET = '@media only screen and (max-width: 991px) and (min-width: 769px)';
// Need to implement a div version of Paragraph since PrettyPrintJson contains an element
// ...which cannot be a child of a element
const PARAGRAPH_BASE_STYLE = css`
margin-top: 0;
display: block;
margin-bottom: 1rem;
margin-block-end: 1em;
margin-inline-end: 0px;
margin-block-start: 1em;
margin-inline-start: 0px;
`;
export const Content = styled.p`
${PARAGRAPH_BASE_STYLE}
`;
export const Paragraph = styled.p`
${PARAGRAPH_BASE_STYLE}
${MEDIA_QUERY_IS_TABLET_OR_DESKTOP} {
max-width: 85%;
}
`;
export const Container = styled.div`
width: 100%;
display: block;
margin-left: auto;
margin-right: auto;
padding: 0.25rem 1.75rem;
${MEDIA_QUERY_IS_MOBILE} {
font-size: 0.96em;
padding: 0.25rem 1.25rem;
}
`;
export const SelectContainer = styled.div`
width: 60%;
margin-top: 1rem;
${MEDIA_QUERY_IS_TABLET} {
width: 75%;
}
${MEDIA_QUERY_IS_MOBILE} {
width: 100%;
}
`;
export const Hr = styled.hr`
border: 0;
margin-top: 1rem;
margin-bottom: 1rem;
padding-bottom: .225rem;
border-top: 1px solid #ddd;
`;
export const Columns = styled.div`
width: 100%;
${MEDIA_QUERY_IS_TABLET_OR_DESKTOP} {
display: flex;
}
`;
export const Column = styled.div<{widthPercent?: number}>`
flex-grow: 1;
flex-basis: 0;
flex-shrink: 1;
display: block;
padding: 0.25rem;
${MEDIA_QUERY_IS_MOBILE} {
padding: 0.25rem 0;
width: 100% !important;
}
${({widthPercent}) =>
widthPercent &&
css`
flex: none;
width: ${widthPercent}%;
`}
`;
export const ListWrapper = styled.div`
${PARAGRAPH_BASE_STYLE}
${MEDIA_QUERY_IS_TABLET_OR_DESKTOP} {
max-width: 85%;
}
&.is-class-list {
max-width: 100% !important;
ul {
li + li {
margin-top: 0.55em !important;
}
}
}
`;
export const List = styled.ul`
display: block;
padding-left: 1.75rem;
margin-block-end: 1em;
list-style-type: disc;
margin-inline-end: 0px;
margin-block-start: 1em;
margin-inline-start: 0px;
padding-inline-start: 20px;
li + li {
margin-top: 0.6em;
}
`;
export const Li = styled.li`
display: list-item;
text-align: match-parent;
`;
export const TextHeader = styled.span`
color: #476582;
font-size: 90%;
line-height: 1.7;
border-radius: 4px;
padding: .175em .475em;
word-break: break-word;
background-color: #f1f1f1;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
`;
export const Title = styled.h2`
font-size: 2rem;
font-weight: 700;
line-height: 1.167;
margin-top: 0.5rem;
margin-bottom: .5rem;
`;
export const SubTitle = styled.h4`
font-weight: 700;
line-height: 1.167;
font-size: 1.65rem;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
letter-spacing: 0.00735em;
`;
export const Button = styled.button`
border: 0;
color: #262626;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
line-height: 1.5;
overflow: visible;
user-select: none;
text-align: center;
border-radius: 3px;
display: inline-block;
vertical-align: middle;
background-color: #eaebec;
padding: 0.375rem 0.75rem;
-webkit-appearance: button;
transition: color 0.2s ease-out, background-color 0.2s ease-out;
:focus {
outline: 0;
}
:hover, :focus {
background-color: #DDDEDF;
}
${MEDIA_QUERY_IS_MOBILE} {
display: block;
width: 100% !important;
}
${MEDIA_QUERY_IS_MOBILE_XS} {
font-size: 0.9em;
}
`;
export const Buttons = styled.div`
> button {
min-width: 6.25rem;
margin-top: 0.5rem;
:not(:last-of-type) {
margin-right: 0.5rem;
}
}
`;
export const Label = styled.label`
width: 100%;
font-weight: 600;
text-align: left;
user-select: none;
display: inline-block;
vertical-align: middle;
color: rgba(0, 0, 0, 0.45);
margin: 0.5rem auto 0.25rem 0;
${MEDIA_QUERY_IS_MOBILE} {
margin: 0 auto 0.15rem 0;
}
`;
export const Checkboxes = styled.div`
font-size: 1rem;
> label {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
:not(:last-of-type) {
margin-right: 1.35rem;
}
}
${MEDIA_QUERY_IS_MOBILE} {
text-align: left;
> label {
width: 100%;
margin-left: auto;
margin-top: 0.425rem;
margin-bottom: 0.425rem;
}
}
`;
export const Card = styled.div`
min-width: 0;
display: flex;
margin: 1.25rem 0;
position: relative;
border-radius: 3px;
word-wrap: break-word;
background-color: #fff;
flex-direction: column;
background-clip: border-box;
border: 1px solid rgba(0, 0, 0, 0.125);
box-shadow: rgb(0 0 0 / 10%) 0px 1px 3px 0px, rgb(0 0 0 / 5%) 0px 5px 15px 0px;
${MEDIA_QUERY_IS_MOBILE} {
border: none;
border-radius: 0;
box-shadow: none;
margin: 0;
}
`;
export const CardHeader = styled.div`
display: flex;
font-size: 1.15rem;
flex-flow: row wrap;
background-color: #fff;
padding: 0.75rem 1.25rem;
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
${MEDIA_QUERY_IS_MOBILE} {
font-size: 1.1rem;
text-align: center;
display: inline-block;
padding: 0 1.15rem 1rem;
}
`;
export const CardBody = styled.div<{ multiComponents?: boolean }>`
flex: 1 1 auto;
min-height: 32rem;
padding: 0.75rem 1.25rem;
${({ multiComponents }) =>
multiComponents &&
css`
> div {
margin-bottom: 3rem;
:first-of-type > label {
margin-top: 0;
}
> label {
font-size: 18px;
margin-bottom: 0.5rem;
}
}
`}
${MEDIA_QUERY_IS_MOBILE} {
padding: 0.5rem 0;
}
`;
export const OtherSpan = styled.span`
opacity: 0.75;
font-size: 0.75em;
margin-top: 0.075em;
margin-left: 0.45em;
`;
export const MenuPortalElement = styled.div<{ menuOpen: boolean; }>`
width: 100%;
margin: 0.5rem 0;
min-height: 115px;
position: relative;
border-radius: 3px;
transition: background-color 0.2s ease-out;
background-color: ${({ menuOpen }) => menuOpen ? 'white' : 'whitesmoke'};
span {
font-weight: 700;
font-size: 1.5em;
text-align: center;
padding: 1.25em 1em;
color: rgba(0,0,0,0.6);
display: ${({ menuOpen }) => menuOpen ? 'none' : 'block'};
}
`;
// =======================================
// Advanced story specific
// =======================================
const SPIN_KEYFRAMES = keyframes`
from {
transform: rotate(0deg);
} to {
transform: rotate(360deg);
}
`;
const SPIN_ANIMATION_CSS = css`animation: ${SPIN_KEYFRAMES} infinite 8s linear;`;
export const ReactSvg = styled.svg<{ isDisabled?: boolean }>`
width: 30px;
height: 30px;
color: #1ea7fd;
fill: currentColor;
display: inline-block;
${({ isDisabled }) => !isDisabled && SPIN_ANIMATION_CSS}
`;
export const ChevronDownSvg = styled.svg<{ menuOpen: boolean }>`
width: 14px;
height: 14px;
fill: currentColor;
transition: transform 0.25s ease-in-out;
${({ menuOpen }) => menuOpen && css`transform: rotate(180deg);`}
`;
export const OptionContainer = styled.div`
height: 100%;
display: flex;
align-items: center;
flex-direction: row;
`;
export const OptionName = styled.span`
color: #515151;
font-weight: 600;
margin-left: 1px;
`;
================================================
FILE: __stories__/helpers/utils/index.ts
================================================
import type { Option } from '../../types';
export const numberWithCommas = (value: number): string => {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
export const getRandomInt = (min: number, max: number): number => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
export const stringifyJavaScriptObj = (data: any = {}): string => {
return JSON.stringify(data, null, 2).replace(/"(\w+)"\s*:/g, '$1:');
};
export const mockHttpRequest = async (delay: number = 500): Promise => {
await new Promise(resolve => setTimeout(resolve, delay));
};
export const createOptions = (count: number): Option[] => {
const options: Option[] = [];
for (let i = 0; i < count; i++) {
const value = i + 1;
options.push({
value,
label: `Option ${value}`
});
}
return options;
};
export const createThemeOptions = (themeEnum: any): Option[] => {
return Object.keys(themeEnum).map((key) => ({
value: themeEnum[key],
label: themeEnum[key]
}));
};
export const createAsyncOptions = (count: number, lblSuffix: string = ''): Option[] => {
const options = createOptions(count);
return options.map(({ value, label }: Option) => ({
value,
label: `${label}${lblSuffix ? (' - ' + lblSuffix) : ''}`
}));
};
export const hexToRgba = (hex: string, alpha: number = 1): string => {
const hexReplacer: string = hex.replace(
/^#?([a-f\d])([a-f\d])([a-f\d])$/i,
(_m, r, g, b) => `#${r}${r}${g}${g}${b}${b}`
);
const alphaValid: number = Math.min(1, Math.max(0, alpha));
const rgbParts: number[] = hexReplacer.substring(1).match(/.{2}/g)!.map((x) => parseInt(x, 16));
const rgbaParts = [...rgbParts, alphaValid].join(',');
return `rgba(${rgbaParts})`;
};
================================================
FILE: __stories__/index.stories.tsx
================================================
import { Select } from '../src';
import { useUpdateEffect } from '../src/hooks';
import type { SelectedOption } from '../src/types';
import { toast, ToastContainer } from 'react-toastify';
import type { CityOption, Option, PackageOption } from './types';
import type { MultiParams, MenuOption, SelectRef, Theme } from '../src';
import React, { useMemo, useRef, useState, useEffect, useCallback, Fragment } from 'react';
import {
OPTION_CLS,
OPTION_FOCUSED_CLS,
OPTION_DISABLED_CLS,
OPTION_SELECTED_CLS,
CARET_ICON_CLS,
CLEAR_ICON_CLS,
LOADING_DOTS_CLS,
AUTOSIZE_INPUT_CLS,
MENU_CONTAINER_CLS,
SELECT_CONTAINER_CLS,
CONTROL_CONTAINER_CLS,
LOADING_MSG_DEFAULT
} from '../src/constants';
import {
Button,
Buttons,
Hr,
Title,
SubTitle,
Label,
Columns,
Column,
Content,
Container,
List,
Li,
ListWrapper,
SelectContainer,
Paragraph,
TextHeader,
Checkboxes,
Card,
CardHeader,
CardBody,
OtherSpan,
OptionContainer,
OptionName,
ReactSvg,
ChevronDownSvg,
MenuPortalElement,
ThemeEnum,
ThemeConfigMap,
Checkbox,
CodeMarkup,
PackageLink,
OptionsCountButton,
mockHttpRequest,
getRandomInt,
useCallbackState,
createAsyncOptions,
createOptions,
stringifyJavaScriptObj,
THEME_DEFAULTS,
THEME_OPTIONS,
THEME_CONFIG,
CITY_OPTIONS,
PACKAGE_OPTIONS,
CLASS_NAME_HTML,
REACT_WINDOW_PACKAGE,
TOAST_CONTAINER_PROPS,
STYLED_COMPONENTS_PACKAGE,
REACT_SVG_PROPS,
REACT_SVG_CIRCLE_PROPS,
REACT_SVG_PATH_PROPS,
CHEVRON_SVG_PROPS,
CHEVRON_DOWN_PATH_PROPS
} from './helpers';
export default {
title: 'React Functional Select/Demos'
};
export const SingleSelect = () => {
const [isInvalid, setIsInvalid] = useCallbackState(false);
const [isLoading, setIsLoading] = useCallbackState(false);
const [isDisabled, setIsDisabled] = useCallbackState(false);
const [isClearable, setIsClearable] = useCallbackState(true);
const [isSearchable, setIsSearchable] = useCallbackState(true);
const getOptionValue = useCallback((option: CityOption): number => option.id, []);
const getOptionLabel = useCallback((option: CityOption): string => `${option.city}, ${option.state}`, []);
useEffect(() => {
isDisabled && setIsInvalid(false);
}, [isDisabled, setIsInvalid]);
return (
Single-Select
In this story's source code, notice that the callback function
properties getOptionValue and getOptionLabel are
wrapped in a useCallback. While not required, strongly prefer
memoization of any callback function property whenever possible. This will boost
performance and reduce the amount of renders as these properties are referenced
in the dependency arrays of useCallbacks, useEffects,
and useMemos. When defined in a functional component, wrap in
a useCallback; when defined in a legacy class component, ensure proper
binding to this. Alternatively, if there is no dependency on any state,
you can opt to hoist functions outside of the component entirely.
The options property should also be memoized. Either consume
it directly from a state management store, or make sure it is stable by
avoiding inline or render-based mutations.
Demo
);
};
export const MultiSelect = () => {
const [openMenuOnClick, setOpenMenuOnClick] = useCallbackState(true);
const [closeMenuOnSelect, setCloseMenuOnSelect] = useCallbackState(true);
const [blurInputOnSelect, setBlurInputOnSelect] = useCallbackState(false);
const [hideSelectedOptions, setHideSelectedOptions] = useCallbackState(true);
const getOptionValue = useCallback(({ id }: CityOption): number => id, []);
const getOptionLabel = useCallback(({ city, state }: CityOption): string => `${city}, ${state}`, []);
// example "renderMultiOptions" property that can be used to further customize labeling for multi-option scenarios
const renderMultiOptions = useCallback(
({ selected, renderOptionLabel }: MultiParams) => (
{selected.length && renderOptionLabel(selected[0].data)}
{selected.length > 1 && (
{`(+${selected.length - 1} other${selected.length > 2 ? 's' : ''})`}
)}
),
[]
);
return (
Multi-Select
Add the isMulti property to allow for multiple selections.
While in multi-select mode, some properties are now applicable and
others become more pertinent.
hideSelectedOptions?: boolean - Hide the
selected option from the menu. Default value is false, however,
if undefined and isMulti is true, then its value
defaults to true.
closeMenuOnSelect?: boolean - Close the
menu of options when the user selects an option. Default value is
false, however, it may be benefical to set this property to true for
convenience in multi-select scenarios.
renderMultiOptions(params: MultiParams) {'=>'} ReactNode -
Optional callback function that can be used to further customize the selection
label in multi-select scenarios. params is an object that contains
the selected and renderOptionLabel properties (array
of selected options and function used to render individual option labels,
respectively). When this function is defined, left and right arrow navigation
of individual options is disabled. When using this property, it may be be a good
idea to set the property backspaceClearsValue to false in
order to avoid accidentally clearing all selections when searching.
Demo
);
};
export const Styling = () => {
const [themeConfig, setThemeConfig] = useState(undefined);
const [selectedOption, setSelectedOption] = useCallbackState(null);
const memoizedMarkupNode = useMemo(() => (
), []);
useEffect(() => {
if (selectedOption) {
const { value } = selectedOption;
setThemeConfig(ThemeConfigMap[value!]);
}
}, [selectedOption]);
const noteCodeStyle = { fontWeight: 500 };
const selectWrapperStyle = { marginTop: '1rem' };
const noteStyle = { fontSize: 'inherit', fontWeight: 700 };
const menuItemSize = selectedOption?.value === ThemeEnum.LARGE_TEXT ? 44 : 35;
return (
Styling
Theming
react-functional-select uses to
handle its styling. The root node is wrapped in
styled-component's ThemeProvider wrapper component which gives all
child styled-components access to the provided theme via React's context API.
To override react-functional-select's default theme, pass an object to
the themeConfig property - any matching properties will replace
those in the default theme.
Starting in v2.0.0, some of the nested objects in
the themeConfig object contain a css property
of type string | FlattenSimpleInterpolation | undefined (default value
is undefined). This property can be used to pass raw CSS styles as a string or wrapped
in exported css function.
Those objects are: select, control, icon, menu, noOptions, multiValue, and input.
Starting in v2.7.0, the control object in themeConfig has
the property focusedCss - which is similar to the css property,
except that it is only applied when the select control is focused (and removed when blurred).
Using Classes
There is also the option to handle styling via CSS classes.
These are the classes that are available:
{SELECT_CONTAINER_CLS}
{CONTROL_CONTAINER_CLS}
{MENU_CONTAINER_CLS}
{AUTOSIZE_INPUT_CLS}
{CARET_ICON_CLS}
{CLEAR_ICON_CLS}
{LOADING_DOTS_CLS}
{OPTION_CLS}, {OPTION_FOCUSED_CLS}, {OPTION_SELECTED_CLS}, {OPTION_DISABLED_CLS}
{memoizedMarkupNode}
Demo
);
};
export const Events = () => {
const options = useMemo