Skip to content

Commit b5e4aec

Browse files
authored
Merge pull request #842 from vinaayakh-aot/feature/FWF-5207-ui-checkbox-main
FWF-5207 : Added checkbox
2 parents 0db5c5a + 42a7c2a commit b5e4aec

File tree

4 files changed

+757
-0
lines changed

4 files changed

+757
-0
lines changed
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import * as React from "react";
2+
import { forwardRef, memo, useCallback, useRef, useMemo } from "react";
3+
import Form from "react-bootstrap/Form";
4+
import { useTranslation } from "react-i18next";
5+
6+
/**
7+
* Checkbox option descriptor for `CustomCheckbox`.
8+
*/
9+
export interface CheckboxOption {
10+
/** Text label or translation key for the option */
11+
label: string;
12+
/** Value associated with this option */
13+
value: any;
14+
/** Optional: disable this specific option */
15+
disabled?: boolean;
16+
/** Optional: called when this option is clicked */
17+
onClick?: () => void;
18+
}
19+
20+
/**
21+
* Props for `CustomCheckbox` checkbox group component.
22+
* Optimized, accessible, and i18n-aware checkbox group.
23+
*/
24+
export interface CustomCheckboxProps
25+
extends Omit<React.ComponentPropsWithoutRef<"fieldset">, "onChange"> {
26+
/** Checkbox options to render */
27+
items: CheckboxOption[];
28+
/** Visual style variant */
29+
variant?: "primary" | "secondary";
30+
/** Group name (shared across all checkbox inputs) */
31+
name?: string;
32+
/** Test ID prefix for automated testing */
33+
dataTestId?: string;
34+
/** Accessible name if no visible legend is provided */
35+
ariaLabel?: string;
36+
/** Optional ID base used to generate item IDs */
37+
id?: string;
38+
/** Currently selected values (controlled) - array of values */
39+
selectedValues?: any[];
40+
/** Alias for selectedValues for backward compatibility */
41+
value?: any[];
42+
/** Called when selection changes; receives the selected values array and event */
43+
onChange?: (values: any[], event: React.ChangeEvent<HTMLInputElement>) => void;
44+
/** Visible group label; rendered as a <legend> */
45+
legend?: string;
46+
/** Backward-compat: alias for legend */
47+
label?: string;
48+
/** Display checkboxes inline (horizontal) */
49+
inline?: boolean;
50+
/** Disable the entire group */
51+
disabled?: boolean;
52+
/** Mark the group as required */
53+
required?: boolean;
54+
/** Additional class for each option wrapper */
55+
optionClassName?: string;
56+
}
57+
58+
/**
59+
* Utility to combine class names conditionally.
60+
*/
61+
const buildClassNames = (
62+
...classes: (string | false | null | undefined)[]
63+
): string => classes.filter(Boolean).join(" ");
64+
65+
// Ensure each checkbox group instance has a unique fallback id/name
66+
let __checkboxGroupInstanceCounter = 0;
67+
68+
/**
69+
* CustomCheckbox: Accessible, memoized checkbox group with i18n support.
70+
*
71+
* Usage:
72+
* <CustomCheckbox
73+
* name="permissions"
74+
* legend="Permissions"
75+
* items={[{label: 'Read', value: 'read'}, {label: 'Write', value: 'write'}]}
76+
* selectedValues={values}
77+
* onChange={(vals) => setValues(vals)}
78+
* />
79+
*/
80+
const CustomCheckboxComponent = forwardRef<HTMLFieldSetElement, CustomCheckboxProps>(
81+
(
82+
{
83+
items,
84+
name,
85+
dataTestId = "",
86+
id = "",
87+
ariaLabel = "",
88+
legend,
89+
label,
90+
inline = true,
91+
variant = "primary",
92+
disabled = false,
93+
required = false,
94+
optionClassName = "",
95+
selectedValues,
96+
value,
97+
onChange,
98+
className = "",
99+
...restProps
100+
},
101+
ref
102+
) => {
103+
const { t } = useTranslation();
104+
105+
// Prefer selectedValues; support value as a legacy alias
106+
const effectiveSelectedValues = selectedValues !== undefined ? selectedValues : value || [];
107+
108+
const handleChange = useCallback(
109+
(optionValue: any) => (event: React.ChangeEvent<HTMLInputElement>) => {
110+
if (disabled) {
111+
event.preventDefault();
112+
event.stopPropagation();
113+
return;
114+
}
115+
116+
const currentValues = [...effectiveSelectedValues];
117+
const isChecked = currentValues.includes(optionValue);
118+
119+
if (isChecked) {
120+
// Remove from selection
121+
const newValues = currentValues.filter(val => val !== optionValue);
122+
onChange?.(newValues, event);
123+
} else {
124+
// Add to selection
125+
const newValues = [...currentValues, optionValue];
126+
onChange?.(newValues, event);
127+
}
128+
},
129+
[disabled, onChange, effectiveSelectedValues]
130+
);
131+
132+
const groupClassName = buildClassNames(
133+
"custom-checkbox",
134+
`custom-checkbox--${variant}`,
135+
inline && "custom-checkbox--inline",
136+
disabled && "is-disabled",
137+
className
138+
);
139+
140+
// fieldset with legend provides native grouping and accessible name
141+
const groupIdRef = useRef<string>(id || name || `checkbox-group-${++__checkboxGroupInstanceCounter}`);
142+
const groupIdBase = groupIdRef.current;
143+
144+
const groupLegend = legend || label;
145+
146+
// Manage focusable items and arrow-key navigation within the group
147+
const optionRefs = useRef<(HTMLInputElement | null)[]>([]);
148+
const enabledIndexes: number[] = useMemo(
149+
() =>
150+
items
151+
.map((opt, idx) => ({ idx, disabled: disabled || !!opt.disabled }))
152+
.filter((x) => !x.disabled)
153+
.map((x) => x.idx),
154+
[items, disabled]
155+
);
156+
157+
// Move focus only (do not toggle). Per WCAG/APG for checkboxes, Space toggles.
158+
const focusByIndex = useCallback(
159+
(targetIndex: number) => {
160+
const input = optionRefs.current[targetIndex];
161+
if (!input) return;
162+
input.focus();
163+
},
164+
[]
165+
);
166+
167+
const findNextEnabledIndex = useCallback(
168+
(start: number, delta: number) => {
169+
if (enabledIndexes.length === 0) return -1;
170+
let startPos = Math.max(0, start);
171+
172+
for (const _ of items) {
173+
const nextPos = (startPos + delta + items.length) % items.length;
174+
if (items[nextPos] && !disabled && !items[nextPos].disabled) {
175+
return nextPos;
176+
}
177+
startPos = nextPos;
178+
}
179+
return -1;
180+
},
181+
[disabled, enabledIndexes.length, items]
182+
);
183+
184+
// Toggle current checkbox on Enter or Space
185+
const handleOptionKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
186+
const { key } = event;
187+
if (key === "Enter" || key === " ") {
188+
event.preventDefault();
189+
event.stopPropagation();
190+
(event.currentTarget as HTMLInputElement).click();
191+
}
192+
}, []);
193+
194+
const handleKeyDown = useCallback(
195+
(event: React.KeyboardEvent<HTMLFieldSetElement>) => {
196+
if (disabled) return;
197+
const { key } = event;
198+
const arrowKeys = ["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", "Home", "End"] as const;
199+
if (!arrowKeys.includes(key as any)) return;
200+
201+
event.preventDefault();
202+
event.stopPropagation();
203+
204+
const activeEl = document.activeElement as HTMLInputElement | null;
205+
const currentIndex = optionRefs.current.indexOf(activeEl);
206+
const fallbackIndex = enabledIndexes.length > 0 ? enabledIndexes[0] : -1;
207+
const baseIndex = currentIndex === -1 ? fallbackIndex : currentIndex;
208+
209+
if (baseIndex === -1) return;
210+
211+
if (key === "Home") {
212+
focusByIndex(enabledIndexes[0]);
213+
return;
214+
}
215+
if (key === "End") {
216+
const lastEnabledIndex = enabledIndexes.length > 0 ? enabledIndexes.at(-1) : -1;
217+
if (lastEnabledIndex !== -1) focusByIndex(lastEnabledIndex);
218+
return;
219+
}
220+
221+
const delta = key === "ArrowRight" || key === "ArrowDown" ? 1 : -1;
222+
const nextIndex = findNextEnabledIndex(baseIndex, delta);
223+
if (nextIndex !== -1) focusByIndex(nextIndex);
224+
},
225+
[disabled, enabledIndexes, findNextEnabledIndex, focusByIndex]
226+
);
227+
228+
return (
229+
<Form className={groupClassName}>
230+
<fieldset
231+
ref={ref}
232+
id={groupIdBase}
233+
aria-label={!groupLegend ? ariaLabel : undefined}
234+
aria-disabled={disabled}
235+
aria-required={required}
236+
disabled={disabled}
237+
onKeyDown={handleKeyDown}
238+
{...restProps}
239+
>
240+
{groupLegend ? <legend>{t(groupLegend)}</legend> : null}
241+
{items.map((option, index) => {
242+
const optionId = `${groupIdBase}-${index + 1}`;
243+
const isChecked = effectiveSelectedValues.includes(option.value);
244+
const isOptionDisabled = disabled || !!option.disabled;
245+
const isTabbable = enabledIndexes.includes(index);
246+
247+
return (
248+
<Form.Check
249+
inline={inline}
250+
label={t(option.label)}
251+
value={String(option.value)}
252+
name={name || groupIdBase}
253+
type="checkbox"
254+
id={optionId}
255+
data-testid={`${dataTestId}-inline-checkbox-${index + 1}`}
256+
key={String(option.value ?? option.label ?? index)}
257+
checked={!!isChecked}
258+
disabled={isOptionDisabled}
259+
onChange={handleChange(option.value)}
260+
onClick={option.onClick}
261+
className={buildClassNames("v8-form-check", optionClassName)}
262+
required={required}
263+
tabIndex={isTabbable ? 0 : -1}
264+
onKeyDown={handleOptionKeyDown}
265+
ref={(el: HTMLInputElement | null) => {
266+
optionRefs.current[index] = el;
267+
}}
268+
/>
269+
);
270+
})}
271+
</fieldset>
272+
</Form>
273+
);
274+
}
275+
);
276+
277+
CustomCheckboxComponent.displayName = "CustomCheckbox";
278+
279+
export const CustomCheckbox = memo(CustomCheckboxComponent);
280+
281+
export type { CustomCheckboxProps as TCustomCheckboxProps };

0 commit comments

Comments
 (0)