diff --git a/src/components/CheckboxGroup.js b/src/components/CheckboxGroup.js new file mode 100644 index 00000000..e9835c02 --- /dev/null +++ b/src/components/CheckboxGroup.js @@ -0,0 +1,172 @@ +import React, { useState, useMemo, useCallback } from "react"; +import PropTypes from "prop-types"; +import { nanoid } from "nanoid"; +import useField from "../hooks/internal/useField"; +import InternalCheckbox from "./internal/InternalCheckbox"; +import Field from "./internal/Field"; +import Stack from "./Stack"; +import { mergeProps, areCheckboxOptionsValid } from "../utils/component"; + +const { COLORS } = InternalCheckbox; + +const DEFAULT_PROPS = { + color: InternalCheckbox.DEFAULT_PROPS.color, + disabled: false, + optional: false, + validate: (value, { isEmpty }) => { + if (isEmpty(value)) { + return "Please make a selection."; + } + + return null; + }, +}; + +CheckboxGroup.COLORS = COLORS; +CheckboxGroup.DEFAULT_PROPS = DEFAULT_PROPS; + +function getKeyFromName(name) { + const index = name.lastIndexOf("."); + + return name.slice(index + 1); +} + +function CheckboxGroup(props) { + const mergedProps = mergeProps( + props, + DEFAULT_PROPS, + {}, + { + color: (color) => COLORS.includes(color), + disabled: (disabled) => typeof disabled === "boolean", + optional: (optional) => typeof optional === "boolean", + options: (options) => areCheckboxOptionsValid(options), + } + ); + const { + name, + label, + options, + helpText, + disabled, + optional, + validate, + validateData, + onChange: onChangeProp, + testId, + } = mergedProps; + + if (!options) { + throw new Error("CheckboxGroup options are invalid"); + } + + const [labelId] = useState(() => `checkbox-group-label-${nanoid()}`); + const [auxId] = useState(() => `checkbox-group-aux-${nanoid()}`); + const isEmpty = useCallback((value) => { + for (const key in value) { + if (value[key] === true) { + return false; + } + } + + return true; + }, []); + const data = useMemo( + () => ({ + isEmpty, + ...(validateData && { data: validateData }), + }), + [isEmpty, validateData] + ); + const { + value, + errors, + hasErrors, + onFocus, + onBlur, + onChange: fieldOnChange, + onMouseDown, + } = useField("CheckboxGroup", { + name, + disabled, + optional, + validate, + data, + }); + const onChange = useCallback( + (event) => { + fieldOnChange(event); + + onChangeProp && + onChangeProp({ + value: { + ...value, + [getKeyFromName(event.target.name)]: event.target.checked, + }, + }); + }, + [fieldOnChange, onChangeProp, value] + ); + + return ( + + + {options.map(({ key, label }) => ( + + {label} + + ))} + + + ); +} + +CheckboxGroup.propTypes = { + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + options: PropTypes.oneOfType([ + PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + }) + ), + PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.string.isRequired, + label: PropTypes.node.isRequired, + }) + ), + ]).isRequired, + color: PropTypes.oneOf(COLORS), + helpText: PropTypes.string, + disabled: PropTypes.bool, + optional: PropTypes.bool, + validate: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]), + validateData: PropTypes.any, + onChange: PropTypes.func, + testId: PropTypes.string, +}; + +export default CheckboxGroup; diff --git a/src/components/Form.js b/src/components/Form.js index 974e541d..95461ced 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -104,7 +104,10 @@ function Form(_props) { 2. Press the Checkbox without releasing it (validation error appears). 3. If you resease the Checkbox now, the validation error disappears. */ - if (state.shouldValidateOnChange || isCheckbox) { + if ( + state.shouldValidateOnChange || + (isCheckbox && target.dataset.parentName === undefined) + ) { newState = setPath(newState, "namesToValidate", [ getParentFieldName(target), ]); diff --git a/src/components/index.js b/src/components/index.js index 222b2d22..daf05b0d 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,6 +1,7 @@ export { default as Accordion } from "./Accordion"; export { default as Button } from "./Button"; export { default as Checkbox } from "./Checkbox"; +export { default as CheckboxGroup } from "./CheckboxGroup"; export { default as Container } from "./Container"; export { default as DatePicker } from "./DatePicker"; export { default as Divider } from "./Divider"; diff --git a/src/components/internal/InternalCheckbox.js b/src/components/internal/InternalCheckbox.js index 512ae6d1..81d8f780 100644 --- a/src/components/internal/InternalCheckbox.js +++ b/src/components/internal/InternalCheckbox.js @@ -1,5 +1,6 @@ -import React from "react"; +import React, { useState } from "react"; import PropTypes from "prop-types"; +import { nanoid } from "nanoid"; import useTheme from "../../hooks/useTheme"; import useBackground from "../../hooks/useBackground"; import useResponsivePropsCSS from "../../hooks/useResponsivePropsCSS"; @@ -65,7 +66,6 @@ function InternalCheckbox(_props) { const { name, parentName, - inputId, color, disabled, isValid, @@ -81,6 +81,7 @@ function InternalCheckbox(_props) { } = props; const theme = useTheme(); const { inputColorMap } = useBackground(); + const [inputId] = useState(() => props.inputId ?? `checkbox-${nanoid()}`); const labelCSS = useResponsivePropsCSS(props, DEFAULT_PROPS, { color: (propsAtBreakpoint, theme, bp) => { return theme.checkbox.getCSS({ @@ -127,7 +128,7 @@ function InternalCheckbox(_props) { InternalCheckbox.propTypes = { name: PropTypes.string.isRequired, parentName: PropTypes.string, - inputId: PropTypes.string.isRequired, + inputId: PropTypes.string, color: PropTypes.oneOf(COLORS), disabled: PropTypes.bool, isValid: PropTypes.bool.isRequired, diff --git a/src/utils/component.js b/src/utils/component.js index 7ef4633a..52ee859b 100644 --- a/src/utils/component.js +++ b/src/utils/component.js @@ -48,6 +48,26 @@ export function areOptionsValid(options) { return true; } +export function areCheckboxOptionsValid(options) { + if (!Array.isArray(options) || options.length === 0) { + return false; + } + + for (const option of options) { + if ( + !( + typeof option.key === "string" && + ((typeof option.label === "string" && option.label.trim() !== "") || + React.isValidElement(option.label)) + ) + ) { + return false; + } + } + + return true; +} + export function areDropdownOptionsValid(options) { if (!Array.isArray(options) || options.length === 0) { return false; diff --git a/website/gatsby-config.js b/website/gatsby-config.js index 7353dae6..9cb16b57 100644 --- a/website/gatsby-config.js +++ b/website/gatsby-config.js @@ -14,6 +14,9 @@ module.exports = { Checkbox: { status: COMPONENT_STATUS.READY, }, + CheckboxGroup: { + status: COMPONENT_STATUS.READY, + }, Container: { status: COMPONENT_STATUS.READY, }, diff --git a/website/src/components/Sidebar.js b/website/src/components/Sidebar.js index 45e48dcd..ca152485 100644 --- a/website/src/components/Sidebar.js +++ b/website/src/components/Sidebar.js @@ -72,7 +72,6 @@ function Sidebar() { query ComponentsQuery { allFile( filter: { relativePath: { regex: "/^components/.*/index.js/" } } - sort: { order: ASC, fields: relativePath } ) { edges { node { @@ -82,14 +81,18 @@ function Sidebar() { } } `); - const components = data.allFile.edges.map(({ node }) => { - const { relativeDirectory } = node; + const components = data.allFile.edges + .map(({ node }) => { + const { relativeDirectory } = node; - return { - componentName: pascalCase(relativeDirectory.split("/")[1]), - href: `/${relativeDirectory}`, - }; - }); + return { + componentName: pascalCase(relativeDirectory.split("/")[1]), + href: `/${relativeDirectory}`, + }; + }) + .sort(({ componentName: name1 }, { componentName: name2 }) => + name1 < name2 ? -1 : 1 + ); return (
diff --git a/website/src/pages/components/checkbox-group/index.js b/website/src/pages/components/checkbox-group/index.js new file mode 100644 index 00000000..ef5d94c7 --- /dev/null +++ b/website/src/pages/components/checkbox-group/index.js @@ -0,0 +1,148 @@ +import React, { useState } from "react"; +import * as allDesignSystem from "basis"; +import ComponentContainer from "../../../components/ComponentContainer"; +import RadioGroupSetting, { + getRadioOptions, + getCheckboxOptions, +} from "../../../components/RadioGroupSetting"; +import { formatCode, nonDefaultProps } from "../../../utils/formatting"; + +const { useTheme, CheckboxGroup } = allDesignSystem; +const { COLORS, DEFAULT_PROPS } = CheckboxGroup; +const scope = allDesignSystem; + +const colorOptions = getRadioOptions(COLORS); +const isOptionalOptions = getCheckboxOptions(); +const hasHelpTextOptions = getCheckboxOptions(); +const isDisabledOptions = getCheckboxOptions(); + +function CheckboxGroupPage() { + const theme = useTheme(); + const [color, setColor] = useState(DEFAULT_PROPS.color); + const [optional, setIsOptional] = useState(DEFAULT_PROPS.optional); + const [hasHelpText, setHasHelpText] = useState( + Boolean(DEFAULT_PROPS.helpText) + ); + const [disabled, setIsDisabled] = useState(DEFAULT_PROPS.disabled); + const code = formatCode(` + const options = [ + { + key: 'apple', + label: 'Apple' + }, + { + key: 'banana', + label: 'Banana' + }, + { + key: 'lemon', + label: 'Lemon' + }, + ]; + + function App() { + const initialValues = { + fruits: { + apple: false, + banana: false, + lemon: false + } + }; + + return ( +
+ + + ); + } + + render(); + `); + + return ( + <> +
+ + + + +
+ + + ); +} + +export default CheckboxGroupPage; diff --git a/website/src/pages/components/checkbox-group/resources.mdx b/website/src/pages/components/checkbox-group/resources.mdx new file mode 100644 index 00000000..72ae973f --- /dev/null +++ b/website/src/pages/components/checkbox-group/resources.mdx @@ -0,0 +1,8 @@ +CheckboxGroup resources are coming soon. + + Meantime, enjoy the{" "} + + Playground + + . + diff --git a/website/src/pages/components/checkbox-group/usage.mdx b/website/src/pages/components/checkbox-group/usage.mdx new file mode 100644 index 00000000..9cdf7710 --- /dev/null +++ b/website/src/pages/components/checkbox-group/usage.mdx @@ -0,0 +1,8 @@ +CheckboxGroup usage is coming soon. + + Meantime, enjoy the{" "} + + Playground + + . + diff --git a/website/src/pages/components/form/index.js b/website/src/pages/components/form/index.js index 6c1f23eb..3bf92f32 100644 --- a/website/src/pages/components/form/index.js +++ b/website/src/pages/components/form/index.js @@ -55,6 +55,20 @@ function FormPage() { value: "chernobyl" } ]; + const fruitOptions = [ + { + key: "apple", + label: "Apple", + }, + { + key: "banana", + label: "Banana", + }, + { + key: "lemon", + label: "Lemon", + }, + ]; const hungryOptions = [ { label: "Yes", @@ -106,6 +120,11 @@ function FormPage() { name: "", relationshipStatus: "", favouriteMovie: "", + fruits: { + apple: false, + banana: false, + lemon: false, + }, likeIceCream: false, hungry: "", salary: { @@ -141,9 +160,10 @@ function FormPage() {