Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 677
Adds a component to support checkbox and radio groups#1657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
a23e32d779e5fe0ad2715f8668cee0addd00d3ae10a67c80ffe9696add088286054f1c910ee6800ecaba115bb4efc84152f70f8ad793c00de50bde8e9bcee94533f20ad1c9e8024b5d3006e97679c2286581b4ca54b43a47b7c1f80a220ad6ea5201504a9d9d721895e5a964f15da266cbab1ae9880c53ec1312a69e99f6b0927296577aa8742444069334dc2691adc6af2f8c3869fff689ace4f33ff5114af7442684d5806c5f3b3c5351acfc2e862f2a998a78625fd0b19a24b20f1a9c17471b137a0772e886d3c183634e06ca73c312f9a0465ee18767e18ef90f255aa8e3ee2f9bfa179e92ea439d31aa9277c33fe7873285ba98a67e8b6781f48699756ef2ae4fff79a5290f40e0bfe3b65f4ff5c858efa2bc6c2062e3f7a6e95dacc434212251974d98e283cbefc0b75fc4c6ac305f96ddc1c4808ce49a846a7bb752a7758212d8047fd269bf73ad10c5c56710b42dc3903156ef7e1cc9300f5ff250a7f81398e541d6d3f03c17ec66a450bb87e6da8a4d0406615bf95cd18effc709d5a9ad9e9542c4f76978c12feea2f769ce577f2fdb21478dbcfedda725b842d1bceFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@primer/react': minor | ||
| --- | ||
| Adds ChoiceFieldset component |
Large diffs are not rendered by default.
Uh oh!
There was an error while loading. Please reload this page.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import React from 'react' | ||
| import {ChoiceInputField} from '..' | ||
| const ChoiceFieldCaption: React.FC = ({children}) => <ChoiceInputField.Caption>{children}</ChoiceInputField.Caption> | ||
| export default ChoiceFieldCaption |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import React from 'react' | ||
| import {ChoiceInputField} from '..' | ||
| const ChoiceFieldLabel: React.FC = ({children}) => <ChoiceInputField.Label>{children}</ChoiceInputField.Label> | ||
| export default ChoiceFieldLabel |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import React, {ComponentProps} from 'react' | ||
| import {Box, useSSRSafeId} from '..' | ||
| import createSlots from '../utils/create-slots' | ||
| import {FormValidationStatus} from '../utils/types/FormValidationStatus' | ||
| import ValidationAnimationContainer from '../_ValidationAnimationContainer' | ||
| import InputValidation from '../_InputValidation' | ||
| import ChoiceFieldsetListItem from './ChoiceFieldsetListItem' | ||
| import ChoiceFieldsetDescription from './ChoiceFieldsetDescription' | ||
| import ChoiceFieldsetLegend from './ChoiceFieldsetLegend' | ||
| import ChoiceFieldsetList from './ChoiceFieldsetList' | ||
| import ChoiceFieldsetValidation from './ChoiceFieldsetValidation' | ||
| export interface ChoiceFieldsetProps<T = Record<string, FormValidationStatus>> { | ||
| children?: React.ReactNode | ||
| /** | ||
| * Whether the fieldset is NOT ready for user input | ||
| */ | ||
| disabled?: boolean | ||
| /** | ||
| * The unique identifier for this fieldset. Used to associate the validation text with the fieldset | ||
| * If an ID is not passed, one will be automatically generated | ||
| */ | ||
| id?: string | ||
| /** | ||
| * The unique identifier used to associate radio inputs with eachother | ||
| * If a name is not passed and the fieldset renders radio inputs, a name will be automatically generated | ||
| */ | ||
| name?: string | ||
| /** | ||
| * The callback that is called when a user toggles a choice on or off | ||
| */ | ||
| onSelect?: (selectedValues: string[]) => void | ||
| /** | ||
| * Whether this field must have a value for the user to complete their task | ||
| */ | ||
| required?: boolean | ||
| /** | ||
| * The selected values | ||
| */ | ||
| selected?: string[] | ||
| /** | ||
| * A map of validation statuses and their associated validation keys. When one of the validation keys is passed to the `validationResult` prop, | ||
| * the associated validation message will be rendered in the correct style | ||
| */ | ||
| validationMap?: T | ||
| /** | ||
| * The key of the validation message to show | ||
| */ | ||
| validationResult?: keyof T | ||
| } | ||
| export interface ChoiceFieldsetContext extends ChoiceFieldsetProps { | ||
| validationMessageId: string | ||
| } | ||
| const {Slots, Slot} = createSlots(['Description', 'ChoiceList', 'Legend', 'Validation']) | ||
| export {Slot} | ||
| const ChoiceFieldset = <T extends Record<string, FormValidationStatus>>({ | ||
| children, | ||
| disabled, | ||
| id, | ||
| name, | ||
| onSelect, | ||
| required, | ||
| selected, | ||
| validationMap, | ||
| validationResult | ||
| }: ChoiceFieldsetProps<T>) => { | ||
| const fieldsetId = useSSRSafeId(id) | ||
| const validationChildren: React.ReactElement[] | undefined | null = React.Children.map(children, child => | ||
| React.isValidElement(child) && child.type === ChoiceFieldsetValidation ? child : null | ||
| )?.filter(Boolean) | ||
| const validationChildToRender = validationChildren?.find(child => child.props.validationKey === validationResult) | ||
| const validationMessageId = validationChildToRender ? `${fieldsetId}-validationMsg` : undefined | ||
| return ( | ||
| <Slots | ||
| context={{ | ||
| disabled, | ||
| name, | ||
| onSelect, | ||
| required, | ||
| selected, | ||
| validationMessageId | ||
| }} | ||
| > | ||
| {slots => { | ||
| const isLegendVisible = React.isValidElement(slots.Legend) && slots.Legend.props.isVisible | ||
| return ( | ||
| <div> | ||
| <Box | ||
| as="fieldset" | ||
| border="none" | ||
| margin={0} | ||
| padding={0} | ||
| aria-describedby={[validationMessageId].filter(Boolean).join(' ')} | ||
| > | ||
| {React.Children.toArray(children).filter( | ||
| child => React.isValidElement(child) && child.type !== ChoiceFieldsetValidation | ||
| )} | ||
| <Box mb={isLegendVisible ? 3 : undefined}> | ||
| {slots.Legend} | ||
| {slots.Description} | ||
| </Box> | ||
| {slots.ChoiceList} | ||
| </Box> | ||
| {validationChildToRender && ( | ||
| <Box mt={3}> | ||
| {validationMap && validationResult && validationMessageId && ( | ||
| <ValidationAnimationContainer show> | ||
| <InputValidation validationStatus={validationMap[validationResult]} id={validationMessageId}> | ||
| {validationChildToRender} | ||
| </InputValidation> | ||
| </ValidationAnimationContainer> | ||
| )} | ||
| </Box> | ||
| )} | ||
| </div> | ||
| ) | ||
| }} | ||
| </Slots> | ||
| ) | ||
| } | ||
| export type InputFieldComponentProps = ComponentProps<typeof ChoiceFieldset> | ||
| export type {ChoiceFieldsetListProps} from './ChoiceFieldsetList' | ||
| export type {ChoiceFieldsetLegendProps} from './ChoiceFieldsetLegend' | ||
| export type {ChoiceFieldProps} from './ChoiceFieldsetListItem' | ||
| export default Object.assign(ChoiceFieldset, { | ||
| Description: ChoiceFieldsetDescription, | ||
| Item: ChoiceFieldsetListItem, | ||
| Legend: ChoiceFieldsetLegend, | ||
| List: ChoiceFieldsetList, | ||
| Validation: ChoiceFieldsetValidation | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import React from 'react' | ||
| import {Text} from '..' | ||
| import {ChoiceFieldsetContext, Slot} from './ChoiceFieldset' | ||
| const ChoiceFieldsetDescription: React.FC = ({children}) => ( | ||
| <Slot name="Description"> | ||
| {({disabled}: ChoiceFieldsetContext) => ( | ||
| <Text color={disabled ? 'fg.muted' : 'fg.default'} fontSize={1}> | ||
| {children} | ||
| </Text> | ||
| )} | ||
| </Slot> | ||
| ) | ||
| export default ChoiceFieldsetDescription |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import React from 'react' | ||
| import {Box} from '..' | ||
| import VisuallyHidden from '../_VisuallyHidden' | ||
| import {ChoiceFieldsetContext, Slot} from './ChoiceFieldset' | ||
| export interface ChoiceFieldsetLegendProps { | ||
| /** | ||
| * Whether to visually hide the fieldset legend | ||
| */ | ||
| visuallyHidden?: boolean | ||
| } | ||
| const ChoiceFieldsetLegend: React.FC<ChoiceFieldsetLegendProps> = ({children, visuallyHidden}) => ( | ||
| <Slot name="Legend"> | ||
| {({required, disabled}: ChoiceFieldsetContext) => ( | ||
| <VisuallyHidden | ||
| as="legend" | ||
| isVisible={!visuallyHidden} | ||
| title={required ? 'required field' : undefined} | ||
| sx={{ | ||
| color: disabled ? 'fg.muted' : undefined, | ||
| fontSize: 2, | ||
| padding: 0 | ||
| }} | ||
| > | ||
| {required ? ( | ||
| <Box display="flex" as="span"> | ||
mperrotti marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| <Box mr={1}>{children}</Box> | ||
| <span>*</span> | ||
| </Box> | ||
| ) : ( | ||
| children | ||
| )} | ||
| </VisuallyHidden> | ||
| )} | ||
| </Slot> | ||
| ) | ||
| export default ChoiceFieldsetLegend | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import React from 'react' | ||
| import styled from 'styled-components' | ||
| import {useSSRSafeId} from '..' | ||
| import {get} from '../constants' | ||
| import {Slot, ChoiceFieldsetContext} from './ChoiceFieldset' | ||
| import ChoiceFieldsetListContext from './ChoiceFieldsetListContext' | ||
| export interface ChoiceFieldsetListProps { | ||
| /** | ||
| * Whether multiple items or a single item can be selected | ||
| */ | ||
| selectionVariant?: 'single' | 'multiple' | ||
| } | ||
| const List = styled.ul` | ||
| display: flex; | ||
| flex-direction: column; | ||
| list-style: none; | ||
| margin: 0; | ||
| padding: 0; | ||
| > li + li { | ||
| margin-top: ${get('space.2')}; | ||
| } | ||
| ` | ||
| const getSelectedCheckboxes = ( | ||
| value: string, | ||
| checked: boolean, | ||
| selectedValues: string[], | ||
| selectionVariant?: ChoiceFieldsetListProps['selectionVariant'] | ||
| ): string[] => { | ||
| if (checked) { | ||
| return selectionVariant === 'multiple' ? [...selectedValues, value] : [value] | ||
| } | ||
| return selectedValues.filter(selectedValue => selectedValue !== value) | ||
| } | ||
| const ChoiceFieldsetList: React.FC<ChoiceFieldsetListProps> = ({selectionVariant, children}) => { | ||
| const ssrSafeUniqueName = useSSRSafeId() | ||
| return ( | ||
| <Slot name="ChoiceList"> | ||
| {({name, onSelect, disabled, selected = []}: ChoiceFieldsetContext) => { | ||
| return ( | ||
| <ChoiceFieldsetListContext.Provider | ||
| value={{ | ||
| disabled, | ||
| selected, | ||
| name: name || ssrSafeUniqueName, | ||
| onChange: e => { | ||
| const updatedSelections = getSelectedCheckboxes( | ||
| e.currentTarget.value, | ||
| e.currentTarget.checked, | ||
| selected, | ||
| selectionVariant | ||
| ) | ||
| onSelect && onSelect(updatedSelections) | ||
| }, | ||
| selectionVariant | ||
| }} | ||
| > | ||
| <List> | ||
| {React.Children.map(children, (child, i) => ( | ||
| <li key={i}>{child}</li> | ||
| ))} | ||
| </List> | ||
| </ChoiceFieldsetListContext.Provider> | ||
| ) | ||
| }} | ||
| </Slot> | ||
| ) | ||
| } | ||
| ChoiceFieldsetList.defaultProps = { | ||
| selectionVariant: 'single' | ||
| } | ||
| export default ChoiceFieldsetList |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import {ChangeEventHandler, createContext} from 'react' | ||
| const ChoiceFieldsetListContext = createContext<{ | ||
| disabled?: boolean | ||
| name: string | ||
| onChange: ChangeEventHandler<HTMLInputElement> | ||
| selected?: string[] | ||
| selectionVariant?: 'single' | 'multiple' | ||
| } | null>(null) | ||
| export default ChoiceFieldsetListContext |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Switch to fragment?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I intentionally wrapped this in a
divso that everything is grouped in 1 DOM node. This way, if this is in a flex or grid container, the whole component would turn into a flex item or grid cell.