Reputation: 357
I have been looking online to create custom check boxes and radio buttons. I have managed to create a checkbox but an issue I am having with the radio box is that clicking on it does not activate or trigger the onChange call on my input. I currently have this:
const customButtons = props => {
const [isChecked, setChecked] = React.useState(false);
const toggleCheck = e => {
setChecked(e.target.checked || !isChecked)
}
return (
<>
<span className={container}>
<input type={props.type} checked={isChecked} onChange={e => toggleCheck(e)} id={props.id} />
<span></span>
</span>
</>
)
}
I have used css to get the span to cover the radio button and made the original radio button display: none;
but when I click on the span circle it does not trigger the click. I added an onClick to the span: <span onClick={toggleCheck}>
but this causes the radio button to be unchecked when clicked twice. What is a better way to implement the custom radio button while maintaining the original behavior?
I am also using scss if that matters.
Upvotes: 2
Views: 12436
Reputation: 1774
Your approach works for both radio
and checkboxes
if the inputs are not set to display: none
, just like normal inputs of course. But if you set them to display: none, you are essentially hiding them from UI events so they won't trigger any click at all.
TLDR: Better approach would be, set the
opacity: 0
on the input, use a label withhtmlFor
to trigger the change. Then style the label pseudo elements to look like radios.
Here is a link to a Live Code Sandbox here
Since you did not provide the styles, it's hard to tell how you visually laid out your custom inputs. With my approach,
Most UIs use radios
when only one option is required for selection and checkboxes
for multiple choices. That said, it gets easy to lift the state from the individual radio options to the parent radio group component, then pass down the radio state, while have the checkboxes control their individual state since they are built to be independent of each other.
Another observation is that your radios lack the name
attribute(Reason why you were seeing multiple clicks with just fewer or no change at all
) making them disjoint from each other. To place them in a group, they need to share a common name
attr, that way you only target just the option value for each radio.
Once all the radio options without a common group(no name attribute) are selected, you cannot unselect them on the UI, so they wont trigger any further onChange events. For this reason, its also advisable to add a reset option to clear the options if they are not mandatory.
Here is the code for each the Radio Input component.
const RadioInput = ({ name, label, value, isChecked, handleChange }) => {
const handleRadioChange = e => {
const { id } = e.currentTarget;
handleChange(id); // Send back id to radio group for comparison
};
return (
<div>
{/* Target this input: opacity 0 */}
<input
type="radio"
className="custom-radio"
name={name}
id={value} // htlmlFor targets this id.
checked={isChecked}
onChange={handleRadioChange}
/>
<label htmlFor={value}>
<span>{label}</span>
</label>
</div>
);
};
See, usually when writing custom inputs to override the native ones, it's easier if you target the label
element and utilize its for
a.k.a htmlFor
attribute to select the input. From previous hustles, it's hard to please all screen readers with custom elements, especially when the native input
you override is set to display none.
In my opinion, it's better to just position it absolutely, set its opacity to zero and let the label trigger it's on change.
Link to Sandbox here
The full Code for the components
App.js
import React, { useState } from "react";
import "./styles.scss";
/*
Let Checkbox the controls its own state.
Styling 'custom-radio', but only make the borders square in .scss file.
*/
const CheckboxInput = ({ name, label }) => {
const [isChecked, setIsChecked] = useState(false);
const toggleCheck = e => {
setIsChecked(() => !isChecked);
};
return (
<div>
<input
type="checkbox"
className="custom-radio"
name={name}
id={name}
checked={isChecked}
onChange={toggleCheck}
/>
<label htmlFor={name}>
<span>{label}</span>
</label>
</div>
);
};
/*
The custom radio input, uses the same styles like the checkbox, and relies on the
radio group parent for its state.
*/
const RadioInput = ({ name, label, value, isChecked, handleChange }) => {
const handleRadioChange = e => {
const { id } = e.currentTarget;
handleChange(id);
};
return (
<div>
<input
type="radio"
className="custom-radio"
name={name}
id={value}
checked={isChecked}
onChange={handleRadioChange}
/>
<label htmlFor={value}>
<span>{label}</span>
</label>
</div>
);
};
/*
This is what control the radio options. Each radio input has the same name attribute
that way you can have multiple groups on the form.
*/
const RadioGropupInput = () => {
const [selectedInput, setSelectedInput] = useState("");
const handleChange = inputValue => {
setSelectedInput(inputValue);
};
return (
<>
<div>
{/*
You could map these values instead from an array of options
And an option to clear the selections if they are not mandatory.
PS: Add aria attributes for accessibility
*/}
<RadioInput
name="option"
value="option-1"
label="First Choice"
isChecked={selectedInput === "option-1"}
handleChange={handleChange}
/>
<RadioInput
name="option"
value="option-2"
label="Second Choice"
isChecked={selectedInput === "option-2"}
handleChange={handleChange}
/>
<RadioInput
name="option"
value="option-3"
label="Third Choice"
isChecked={selectedInput === "option-3"}
handleChange={handleChange}
/>
</div>
</>
);
};
export default () => (
<div className="App">
<RadioGropupInput />
<hr />
<CheckboxInput name="remember-me" label="Remember Me" />
<CheckboxInput name="subscribe" label="Subscribe" />
</div>
);
The Styles
.custom-radio {
/* Hide the input element and target the next label that comes after it in the DOM */
position: absolute;
display: inline-block;
opacity: 0;
& + label {
cursor: pointer;
display: inline-block;
position: relative;
white-space: nowrap;
line-height: 1rem;
margin: 0 0 1.5rem 0;
padding: 0 0 0 1rem;
transition: all 0.5s ease-in-out;
span {
margin-left: 0.5rem;
}
/* Styles these pseudo elements to look like radio inputs. */
&::before,
&::after {
content: '';
position: absolute;
color: #f5f5f5;
text-align: center;
border-radius: 0;
top: 0;
left: 0;
width: 1rem;
height: 1rem;
transition: all 0.5s ease-in-out;
}
&::before {
text-rendering: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1rem;
border-radius: 0;
background-color: #ffffff;
color: #ffffff;
box-shadow: inset 0 0 0 1px #666565, inset 0 0 0 1rem #ffffff,
inset 0 0 0 1rem #6b0707;
}
&:hover,
&:focus,
&:active {
color: red;
font-weight: bolder;
transition: all 0.3s ease;
outline: none;
&::before {
color: #ffffff;
animation-duration: 0.5s;
animation-name: changeSizeAnim;
animation-iteration-count: infinite;
animation-direction: alternate;
box-shadow: inset 0 0 0 1px #6b0707, inset 0 0 0 16px #ffffff,
inset 0 0 0 16px #6b0707;
}
}
}
&:focus,
&:hover,
&:checked {
& + label {
color: #220000 !important;
}
& + label::before {
animation-duration: 0.3s;
animation-name: selectCheckboxAnim;
animation-iteration-count: 1;
animation-direction: alternate;
border: solid 1px rgba(255, 0, 0, 0.5);
box-shadow: inset 0 0 0 1px #bc88d4, inset 0 0 0 0 #ffffff,
inset 0 0 1px 2px #6d1717;
}
}
&:checked {
& + label::before {
content: '✔'; /* Swap out this emoji checkmark with maybe an icon font of base svg*/
background-color: #d43333;
color: #ffffff;
border: solid 1px rgba(202, 50, 230, 0.5);
box-shadow: inset 0 0 0 1px #bc88d4, inset 0 0 0 0 #ffffff,
inset 0 0 0 16px #d43333;
}
}
& + label {
&::before {
border-radius: 50%;
}
}
&[type=checkbox] {
& + label {
&::before {
/* Remove the border radius on the checkboxes for a square effect */
border-radius: 0;
}
}
}
@keyframes changeSizeAnim {
from {
box-shadow: 0 0 0 0 #d43333,
inset 0 0 0 1px #d43333,
inset 0 0 0 16px #FFFFFF,
inset 0 0 0 16px #d43333;
}
to {
box-shadow: 0 0 0 1px #d43333,
inset 0 0 0 1px #d43333,
inset 0 0 0 16px #FFFFFF,
inset 0 0 0 16px #d43333;
}
}
/* Add some animations like a boss, cause why would you hustle to build
a custom component when you can't touch this!
*/
@keyframes selectCheckboxAnim {
0% {
box-shadow: 0 0 0 0 #bc88d4,
inset 0 0 0 2px #FFFFFF,
inset 0 0 0 3px #d43333,
inset 0 0 0 16px #FFFFFF,
inset 0 0 0 16px #d43333;
}
100% {
box-shadow: 0 0 20px 8px #eeddee,
inset 0 0 0 0 white,
inset 0 0 0 1px #bc88d4,
inset 0 0 0 0 #FFFFFF,
inset 0 0 0 16px #d43333;
}
}
}
/* Styles used to test out and reproduce out your approach */
.container.control-experiment {
background: #fee;
span,
input {
display: flex;
border: solid 1px red;
width: 2rem;
height: 2rem;
line-height: 2rem;
display: inline-block;
}
input {
position: absolute;
margin: 0;
padding: 0;
}
input[type='radio'] {
// display: none; /* Uncommenting this out makes all your inputs unsable.*/
}
}
I repeat for emphasis, don't forget to add in the aria attributes for the custom input. Again you can test out the live Sandbox
Upvotes: 5
Reputation: 5854
I tried your example and used log in toggleCheck and it is triggered for both radio and checkbox.
CustomButtons Component
import React from "react";
const CustomButtons = props => {
const [isChecked, setChecked] = React.useState(false);
const toggleCheck = e => {
console.log(e);
setChecked(e.target.checked || !isChecked)
};
return (
<>
<span>
<input type={props.type} checked={isChecked} onChange={e => toggleCheck(e)} id={props.id}/>
<span>{props.text}</span>
</span>
</>
)
};
export default CustomButtons
How to use CustomButtons in App.js
<CustomButtons type={"radio"} text={"One"}/>
<CustomButtons type={"radio"} text={"Two"}/>
<CustomButtons type={"checkbox"} text={"One"}/>
<CustomButtons type={"checkbox"} text={"Two"}/>
Upvotes: 1