feat: enhance accessibility and styling for form components including Checkbox, Dropdown, Radio, Slider, and Text

This commit is contained in:
alexsparkes
2026-01-25 20:12:51 +00:00
parent 5392e4b27d
commit c186c54749
11 changed files with 486 additions and 88 deletions

View File

@@ -33,15 +33,16 @@ const Checkbox = memo((props) => {
EventBus.emit('refresh', props.category);
}, [checked, props]);
const handleKeyDown = useCallback((e) => {
if ((e.key === ' ' || e.key === 'Enter') && !props.disabled) {
e.preventDefault();
handleChange();
}
}, [handleChange, props.disabled]);
return (
<div className={`checkbox-wrapper ${props.disabled ? 'disabled' : ''}`}>
<span className="checkbox-label">{props.text}</span>
<div
className={`checkbox-box ${checked ? 'checked' : ''}`}
onClick={props.disabled ? undefined : handleChange}
>
{checked && <MdCheck />}
</div>
<input
type="checkbox"
name={props.name}
@@ -49,8 +50,12 @@ const Checkbox = memo((props) => {
onChange={handleChange}
disabled={props.disabled || false}
className="checkbox-input"
aria-hidden="true"
aria-label={props.text}
onKeyDown={handleKeyDown}
/>
<div className={`checkbox-box ${checked ? 'checked' : ''}`}>
{checked && <MdCheck />}
</div>
</div>
);
});

View File

@@ -1,6 +1,24 @@
@use 'scss/variables' as *;
@use 'scss/mixins' as *;
@include keyframes(checkScale) {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.checkbox-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
@@ -13,8 +31,16 @@
cursor: not-allowed;
}
&:hover:not(.disabled) .checkbox-label {
@include themed {
color: t($link);
}
}
.checkbox-label {
flex: 1;
transition: color 0.2s ease;
pointer-events: none;
@include themed {
color: t($color);
@@ -28,17 +54,27 @@
width: 24px;
height: 24px;
border-radius: 6px;
transition: 0.2s ease;
transition: all 0.2s ease;
cursor: pointer;
flex-shrink: 0;
pointer-events: none;
@include themed {
border: 2px solid t($modal-sidebarActive);
background: t($modal-sidebar);
color: t($color);
&:hover {
&:hover:not(.disabled) {
border-color: t($color);
transform: scale(1.05);
}
}
&:active:not(.disabled) {
transform: scale(0.95);
@include themed {
box-shadow: 0 0 0 4px rgba(255, 92, 37, 0.1);
}
}
@@ -47,6 +83,10 @@
background: t($link);
border-color: t($link);
}
svg {
@include animation(checkScale 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55));
}
}
svg {
@@ -57,7 +97,22 @@
.checkbox-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
cursor: pointer;
margin: 0;
&:focus-visible + .checkbox-box {
@include themed {
box-shadow: 0 0 0 3px t($link);
}
}
&:disabled {
cursor: not-allowed;
}
}
}

View File

@@ -1,6 +1,6 @@
import variables from 'config/variables';
import { memo, useState, useCallback, useRef, useEffect } from 'react';
import { MdExpandMore } from 'react-icons/md';
import { MdExpandMore, MdCheck } from 'react-icons/md';
import EventBus from 'utils/eventbus';
@@ -11,12 +11,15 @@ const Dropdown = memo((props) => {
localStorage.getItem(props.name) || props.items[0]?.value,
);
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const containerRef = useRef(null);
const optionsRef = useRef([]);
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setIsOpen(false);
setFocusedIndex(-1);
}
};
@@ -34,6 +37,7 @@ const Dropdown = memo((props) => {
setValue(newValue);
setIsOpen(false);
setFocusedIndex(-1);
if (!props.noSetting) {
localStorage.setItem(props.name, newValue);
@@ -56,27 +60,85 @@ const Dropdown = memo((props) => {
[value, props],
);
const handleKeyDown = useCallback(
(e) => {
if (props.disabled) return;
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
setIsOpen(!isOpen);
break;
case 'Escape':
setIsOpen(false);
setFocusedIndex(-1);
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setFocusedIndex((prev) => (prev < props.items.filter((i) => i !== null).length - 1 ? prev + 1 : prev));
}
break;
case 'ArrowUp':
e.preventDefault();
if (isOpen) {
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
}
break;
}
},
[isOpen, props.items, props.disabled],
);
const handleOptionKeyDown = useCallback(
(e, item) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange(item.value);
}
},
[onChange],
);
const id = 'dropdown' + props.name;
const label = props.label || '';
const selectedItem = props.items.find((item) => item?.value === value);
return (
<div className={`dropdown ${id}`} ref={containerRef}>
<div className={`dropdown ${id} ${props.disabled ? 'disabled' : ''}`} ref={containerRef}>
{label && <label className="dropdown-label">{label}</label>}
<div className="dropdown-control" onClick={() => setIsOpen(!isOpen)}>
<div
className="dropdown-control"
onClick={() => !props.disabled && setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
role="button"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={label || props.name}
tabIndex={props.disabled ? -1 : 0}
>
<span className="dropdown-value">{selectedItem?.text || value}</span>
<MdExpandMore className={`dropdown-arrow ${isOpen ? 'open' : ''}`} />
</div>
{isOpen && (
<div className="dropdown-menu">
{props.items.map((item) =>
<div className="dropdown-menu" role="listbox">
{props.items.map((item, index) =>
item !== null ? (
<div
key={id + item.value}
className={`dropdown-option ${value === item.value ? 'selected' : ''}`}
ref={(el) => (optionsRef.current[index] = el)}
className={`dropdown-option ${value === item.value ? 'selected' : ''} ${index === focusedIndex ? 'focused' : ''}`}
onClick={() => onChange(item.value)}
onKeyDown={(e) => handleOptionKeyDown(e, item)}
role="option"
aria-selected={value === item.value}
tabIndex={0}
>
{item.text}
<span className="dropdown-option-text">{item.text}</span>
{value === item.value && <MdCheck className="dropdown-option-check" />}
</div>
) : null,
)}

View File

@@ -1,10 +1,29 @@
@use 'scss/variables' as *;
@use 'scss/mixins' as *;
@include keyframes(dropdownSlideIn) {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.dropdown {
position: relative;
width: 300px;
margin-top: 10px;
&.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.dropdown-label {
display: block;
margin-bottom: 8px;
@@ -25,7 +44,8 @@
height: 56px;
padding: 0 16px;
cursor: pointer;
transition: 0.2s ease;
transition: all 0.2s ease;
outline: none;
@include themed {
background: t($modal-sidebar);
@@ -37,6 +57,15 @@
border-color: t($color);
}
}
&:focus-visible {
outline: none;
@include themed {
border-color: t($link);
box-shadow: 0 0 0 3px t($link);
}
}
}
.dropdown-value {
@@ -44,6 +73,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.2s ease;
}
.dropdown-arrow {
@@ -68,6 +98,7 @@
max-height: 250px;
overflow-y: auto;
z-index: 100;
@include animation(dropdownSlideIn 0.2s ease-out);
@include themed {
background: t($modal-background);
@@ -75,24 +106,81 @@
border-radius: t($borderRadius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
@include themed {
background: t($modal-sidebar);
}
}
&::-webkit-scrollbar-thumb {
@include themed {
background: t($modal-sidebarActive);
border-radius: 3px;
}
&:hover {
@include themed {
background: t($color);
}
}
}
}
.dropdown-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
cursor: pointer;
transition: 0.2s ease;
transition: all 0.15s ease;
outline: none;
@include themed {
color: t($color);
&:hover {
background: t($modal-sidebarActive);
padding-left: 20px;
}
&.selected {
background: t($modal-sidebar);
font-weight: 500;
}
&.focused {
background: t($modal-sidebarActive);
border-left: 2px solid t($link);
}
}
.dropdown-option-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-option-check {
flex-shrink: 0;
font-size: 14px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
@include themed {
background: t($link);
color: white;
}
}
}
}

View File

@@ -62,21 +62,23 @@ const Radio = memo((props) => {
{props.options.map((option) => (
<label
key={option.value}
className={`radio-option ${value === option.value ? 'selected' : ''}`}
onClick={() => handleChange(option.value)}
className={`radio-option ${value === option.value ? 'selected' : ''} ${option.disabled || props.disabled ? 'disabled' : ''}`}
>
<span className="radio-label">{option.name}</span>
<div className="radio-circle">
{value === option.value && <div className="radio-dot" />}
</div>
<input
type="radio"
name={props.name}
value={option.value}
checked={value === option.value}
onChange={() => handleChange(option.value)}
disabled={option.disabled || props.disabled || false}
className="radio-input"
aria-label={option.name}
tabIndex={0}
/>
<div className="radio-circle">
{value === option.value && <div className="radio-dot" />}
</div>
</label>
))}
</div>

View File

@@ -1,4 +1,21 @@
@use 'scss/variables' as *;
@use 'scss/mixins' as *;
@include keyframes(radioDotScale) {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.radio-group {
width: 100%;
@@ -33,33 +50,46 @@
}
.radio-option {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 16px 20px;
transition: 0.2s ease;
transition: all 0.2s ease;
@include themed {
background: t($modal-sidebar);
border-radius: t($borderRadius);
box-shadow: 0 0 0 1px t($modal-sidebarActive);
&:hover {
&:hover:not(.disabled) {
background: t($modal-secondaryColour);
transform: translateY(-1px);
}
}
&:active:not(.disabled) {
transform: translateY(0);
}
&.selected .radio-circle {
@include themed {
border-color: t($link);
}
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
}
.radio-label {
flex: 1;
font-size: 15px;
pointer-events: none;
@include themed {
color: t($color);
@@ -73,21 +103,33 @@
width: 22px;
height: 22px;
border-radius: 50%;
transition: 0.2s ease;
transition: all 0.2s ease;
cursor: pointer;
flex-shrink: 0;
margin-left: 20px;
pointer-events: none;
@include themed {
border: 2px solid t($modal-sidebarActive);
background: t($modal-secondaryColour);
}
&:hover:not(.disabled) {
transform: scale(1.1);
}
&:active:not(.disabled) {
@include themed {
box-shadow: 0 0 0 4px rgba(255, 92, 37, 0.1);
}
}
}
.radio-dot {
width: 12px;
height: 12px;
border-radius: 50%;
@include animation(radioDotScale 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55));
@include themed {
background: t($link);
@@ -96,7 +138,22 @@
.radio-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
cursor: pointer;
margin: 0;
&:focus-visible + .radio-circle {
@include themed {
box-shadow: 0 0 0 3px t($link);
}
}
&:disabled {
cursor: not-allowed;
}
}
}

View File

@@ -99,6 +99,11 @@ const SliderComponent = memo((props) => {
max={Number(props.max)}
step={Number(props.step) || 1}
style={{ '--slider-percentage': `${percentage}%` }}
aria-label={props.title}
aria-valuemin={Number(props.min)}
aria-valuemax={Number(props.max)}
aria-valuenow={Number(value)}
disabled={props.disabled || false}
/>
{props.marks && props.marks.length > 0 && (
<div className="slider-marks">

View File

@@ -72,7 +72,7 @@
height: 20px;
border-radius: 50%;
cursor: pointer;
transition: 0.2s ease;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
@include themed {
@@ -82,6 +82,7 @@
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
}
}
@@ -90,7 +91,7 @@
height: 20px;
border-radius: 50%;
cursor: pointer;
transition: 0.2s ease;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
border: none;
@@ -101,6 +102,52 @@
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
}
}
&:focus-visible {
outline: none;
&::-webkit-slider-thumb {
@include themed {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2), 0 0 0 3px t($link);
}
transform: scale(1.15);
}
&::-moz-range-thumb {
@include themed {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2), 0 0 0 3px t($link);
}
transform: scale(1.15);
}
}
&:active:not(:disabled) {
&::-webkit-slider-thumb {
transform: scale(1.2);
}
&::-moz-range-thumb {
transform: scale(1.2);
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&::-webkit-slider-thumb {
cursor: not-allowed;
transform: scale(1) !important;
}
&::-moz-range-thumb {
cursor: not-allowed;
transform: scale(1) !important;
}
}
}

View File

@@ -52,7 +52,15 @@ const Text = memo((props) => {
<div className="text-field-container">
{textarea === true ? (
<div className={`text-field ${customcss ? 'customcss' : ''}`}>
{title && <label className="text-field-label">{title}</label>}
{title && (
<div className="text-field-header">
<label className="text-field-label">{title}</label>
<span className="text-field-reset" onClick={resetItem}>
<MdRefresh />
{variables.getMessage('modals.main.settings.buttons.reset')}
</span>
</div>
)}
<textarea
value={value}
onChange={handleChange}
@@ -63,7 +71,15 @@ const Text = memo((props) => {
</div>
) : (
<div className="text-field">
{title && <label className="text-field-label">{title}</label>}
{title && (
<div className="text-field-header">
<label className="text-field-label">{title}</label>
<span className="text-field-reset" onClick={resetItem}>
<MdRefresh />
{variables.getMessage('modals.main.settings.buttons.reset')}
</span>
</div>
)}
<input
type="text"
value={value}
@@ -73,10 +89,6 @@ const Text = memo((props) => {
/>
</div>
)}
<span className="link" onClick={resetItem}>
<MdRefresh />
{variables.getMessage('modals.main.settings.buttons.reset')}
</span>
</div>
);
});

View File

@@ -3,17 +3,42 @@
.text-field-container {
display: flex;
flex-direction: column;
gap: 10px;
width: 300px;
margin-top: 10px;
}
.link {
.text-field {
display: flex;
flex-direction: column;
gap: 8px;
.text-field-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.text-field-label {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($subColor);
}
}
.text-field-reset {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-size: 14px;
width: fit-content;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($link);
@@ -24,24 +49,7 @@
}
svg {
font-size: 16px;
}
}
}
.text-field {
display: flex;
flex-direction: column;
gap: 8px;
.text-field-label {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($subColor);
font-size: 12px;
}
}

View File

@@ -1,41 +1,98 @@
@use 'variables' as *;
@use 'mixins' as *;
.Toastify__toast-body {
font-size: 16px !important;
padding: 15px 20px !important;
}
.Toastify__toast {
border-radius: 12px !important;
height: auto !important;
width: auto !important;
min-width: auto !important;
padding: 10px !important;
}
.Toastify__close-button {
align-self: center !important;
color: var(--modal-text) !important;
}
/* .Toastify__progress-bar--default {
height: 3px !important;
background: var(--modal-text) !important;
color: var(--modal-text) !important;
} */
// Toast container
.Toastify__toast-container {
width: auto !important;
padding: 16px !important;
}
.Toastify__toast--default {
@extend %basic;
// Main toast styling with glassmorphism effect
.Toastify__toast {
@include themed() {
background: t($background) !important;
color: t($color) !important;
box-shadow: t($boxShadow), 0 8px 32px rgb(0 0 0 / 20%) !important;
}
border-radius: $borderRadius !important;
-webkit-backdrop-filter: blur(15px) saturate(180%) !important;
backdrop-filter: blur(15px) saturate(180%) !important;
padding: 16px 20px !important;
min-height: 64px !important;
width: auto !important;
min-width: 300px !important;
max-width: 500px !important;
font-family: inherit !important;
box-sizing: border-box !important;
transition: all 0.3s ease !important;
&:hover {
transform: translateY(-2px) !important;
box-shadow: 0 0 0 1px #484848, 0 12px 40px rgb(0 0 0 / 30%) !important;
}
}
.Toastify__toast--info {
@extend %basic;
// Toast body
.Toastify__toast-body {
padding: 0 !important;
font-size: 15px !important;
line-height: 1.5 !important;
margin: 0 !important;
@include themed() {
color: t($color) !important;
}
}
.Toastify__progress-bar--info {
background-color: gold !important;
// Close button
.Toastify__close-button {
@include themed() {
color: t($color) !important;
opacity: 0.6 !important;
}
align-self: center !important;
transition: all 0.2s ease !important;
&:hover {
opacity: 1 !important;
}
}
// Progress bar base styling
.Toastify__progress-bar {
height: 4px !important;
border-radius: 0 0 $borderRadius $borderRadius !important;
&--default,
&--info {
@include themed() {
background: linear-gradient(90deg, t($link) 0%, #dd3b67 100%) !important;
}
}
&--success {
background: linear-gradient(90deg, rgb(46 213 115) 0%, rgb(26 188 156) 100%) !important;
}
&--warning {
background: linear-gradient(90deg, #ffb032 0%, #ff9500 100%) !important;
}
&--error {
background: linear-gradient(90deg, rgb(255 71 87) 0%, #dd3b67 100%) !important;
}
}
// Toast type variants - all use the base glassmorphism style
.Toastify__toast--default,
.Toastify__toast--info,
.Toastify__toast--success,
.Toastify__toast--warning,
.Toastify__toast--error {
@include themed() {
background: t($background) !important;
color: t($color) !important;
}
}