fix(ui): fix checkbox tick not visible in light/dark mode

The checkmark SVG was nested inside a span, making peer-checked
selectors ineffective since peer only works on siblings.

Fix by moving the checkmark and indeterminate SVGs to be direct
siblings of the hidden input element, allowing Tailwind's peer-checked
and peer-data-[indeterminate] selectors to properly toggle visibility.
This commit is contained in:
Ajax Davis 2025-12-12 12:51:37 +10:00
parent 6482c31f17
commit 48aa12f351
2 changed files with 47 additions and 43 deletions

View file

@ -1,13 +1,7 @@
import { cn } from '@tpmjs/utils/cn';
import { forwardRef, useEffect, useRef } from 'react';
import type { CheckboxProps } from './types';
import {
checkboxLabelVariants,
checkboxUIVariants,
checkboxVariants,
checkmarkVariants,
indeterminateVariants,
} from './variants';
import { checkboxLabelVariants, checkboxUIVariants, checkboxVariants } from './variants';
/**
* Checkbox component
@ -93,37 +87,8 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
/>
);
const checkboxUI = (
<span className={cn(checkboxUIVariants({ state, size }))}>
{/* Checkmark icon */}
<svg
className={cn(checkmarkVariants({ size }))}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M13.5 4.5L6 12L2.5 8.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{/* Indeterminate line */}
<svg
className={cn(indeterminateVariants({ size }))}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M4 8H12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</span>
);
// Visual checkbox box (background, border, etc.) - no icons inside
const checkboxUI = <span className={cn(checkboxUIVariants({ state, size }))} />;
const labelElement = label ? (
<label
@ -137,9 +102,48 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
return (
<div className={cn('inline-flex items-center', disabled && 'cursor-not-allowed')}>
{labelPosition === 'left' && labelElement}
<div className="relative inline-flex">
<div className="relative inline-flex items-center justify-center">
{checkboxInput}
{checkboxUI}
{/* Checkmark icon - sibling of input so peer-checked works */}
<svg
className={cn(
'pointer-events-none absolute inset-0 opacity-0 scale-0 transition-all duration-200 text-primary-foreground',
'peer-checked:opacity-100 peer-checked:scale-100',
size === 'sm' && 'h-4 w-4',
size === 'md' && 'h-5 w-5',
size === 'lg' && 'h-6 w-6'
)}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M13.5 4.5L6 12L2.5 8.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{/* Indeterminate line - sibling of input so peer selectors work */}
<svg
className={cn(
'pointer-events-none absolute inset-0 opacity-0 scale-0 transition-all duration-200 text-primary-foreground',
'peer-data-[indeterminate=true]:opacity-100 peer-data-[indeterminate=true]:scale-100',
'peer-checked:opacity-0 peer-checked:scale-0',
size === 'sm' && 'h-4 w-4',
size === 'md' && 'h-5 w-5',
size === 'lg' && 'h-6 w-6'
)}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M4 8H12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
{labelPosition === 'right' && labelElement}
</div>

View file

@ -1,5 +1,5 @@
import { createVariants } from '../system/variants';
import { formControlBase } from '../system/formVariants';
import { createVariants } from '../system/variants';
/**
* Checkbox variant definitions
@ -100,17 +100,17 @@ export const checkboxUIVariants = createVariants({
/**
* Checkmark icon variants
* Note: Uses group-[] selectors because the SVG is nested inside the checkboxUI span,
* not a direct sibling of the input. The checkboxUI span has peer-checked classes.
*/
export const checkmarkVariants = createVariants({
base: [
// Positioning
'absolute inset-0',
// Hidden by default
// Hidden by default, visible when parent has peer-checked state
'opacity-0 scale-0',
// Transitions
'transition-all duration-200',
// Visible when checked
'peer-checked:opacity-100 peer-checked:scale-100',
// Color
'text-primary-foreground',
].join(' '),