Skip to content

Commit 40f621a

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(ui): styled Checkbox primitive matching the form controls (#518) (#519)
Native checkboxes kept the OS accent + a bright white box, clashing with the themed text inputs — worst in dark mode, where inputs are dark but the boxes stayed white (the dark-mode input remap intentionally skips native checkboxes). Add a generic @dar/ui Checkbox: an appearance-none box with the same border as the inputs and a transparent background (so it inherits the same card/table surface in both themes, exactly like an input), a primary fill + inline-SVG tick when checked, a focus ring, and a disabled state. No icon-library dependency (the tick is inline SVG, so @dar/ui stays dependency-free). Swap every native checkbox to it: the changelist select-all / row select (Table), the boolean field + M2M multi-select (FieldInput), the inline delete toggle + boolean cell (InlineEditor), and the column customizer (ListPage). Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b2e6590 commit 40f621a

6 files changed

Lines changed: 56 additions & 22 deletions

File tree

frontend/apps/web/src/pages/ListPage.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
usePersistedState,
2121
writeJSON,
2222
} from '@dar/customization';
23-
import { Breadcrumb, Button, Card, EmptyState, Modal, Skeleton, Table } from '@dar/ui';
23+
import { Breadcrumb, Button, Card, Checkbox, EmptyState, Modal, Skeleton, Table } from '@dar/ui';
2424
import { FieldValueView } from '@dar/details';
2525
import { DateHierarchyBar } from '@dar/list';
2626
import { FilterBar } from '@dar/search';
@@ -682,8 +682,7 @@ export function ListPage() {
682682
locked && !pk ? 'text-gray-400' : 'text-gray-800'
683683
}`}
684684
>
685-
<input
686-
type="checkbox"
685+
<Checkbox
687686
checked={visible}
688687
disabled={locked}
689688
onChange={() => toggleColumn(c.name, visibleColumnCount)}

frontend/packages/form/src/FieldInput.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import type { FieldDescriptor, FieldValue, WriteValue } from '@dar/data';
1212
import { FieldValueView } from '@dar/details';
13+
import { Checkbox } from '@dar/ui';
1314

1415
import { AutocompleteInput } from './AutocompleteInput';
1516

@@ -51,13 +52,7 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr
5152

5253
if (field.type === 'boolean') {
5354
control = (
54-
<input
55-
id={id}
56-
type="checkbox"
57-
checked={value === true}
58-
onChange={(e) => onChange(e.target.checked)}
59-
className="h-4 w-4 rounded border-gray-300"
60-
/>
55+
<Checkbox id={id} checked={value === true} onChange={(e) => onChange(e.target.checked)} />
6156
);
6257
} else if (field.type === 'text') {
6358
control = (
@@ -134,16 +129,14 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr
134129
const key = String(c.value);
135130
return (
136131
<label key={key} className="flex items-center gap-2 text-sm">
137-
<input
138-
type="checkbox"
132+
<Checkbox
139133
checked={selected.has(key)}
140134
onChange={(e) => {
141135
const next = new Set(selected);
142136
if (e.target.checked) next.add(key);
143137
else next.delete(key);
144138
onChange(Array.from(next));
145139
}}
146-
className="h-4 w-4 rounded border-gray-300"
147140
/>
148141
{c.label}
149142
</label>

frontend/packages/form/src/InlineEditor.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import { useEffect, useMemo, useState } from 'react';
1818

1919
import type { InlineDescriptor, InlineWriteItem, WriteValue } from '@dar/data';
20-
import { Button } from '@dar/ui';
20+
import { Button, Checkbox } from '@dar/ui';
2121

2222
interface EditRow {
2323
key: string;
@@ -152,8 +152,7 @@ export function InlineEditor({ inline, onItems }: InlineEditorProps) {
152152
row.pk !== null ? (
153153
inline.can_delete ? (
154154
<label className="flex items-center gap-1 text-xs text-gray-500">
155-
<input
156-
type="checkbox"
155+
<Checkbox
157156
checked={row.deleted}
158157
// Block removing below min_num: a not-yet-deleted row can't be
159158
// checked once at the floor (an already-checked one can still
@@ -276,8 +275,7 @@ function InlineCellInput({ type, value, disabled, onChange }: InlineCellInputPro
276275

277276
if (type === 'boolean') {
278277
return (
279-
<input
280-
type="checkbox"
278+
<Checkbox
281279
checked={value === true}
282280
disabled={disabled}
283281
onChange={(e) => onChange(e.target.checked)}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Styled checkbox primitive. The native checkbox is intentionally
2+
// excluded from the app's dark-mode input remap (it keeps its OS accent),
3+
// which leaves a bright native box that clashes with the themed form
4+
// controls. This renders an `appearance-none` box styled to match the
5+
// text inputs — same border, the same surface (transparent, so it picks
6+
// up the card/table background in both themes exactly like an input) —
7+
// with a primary fill + check mark when checked. Generic, props-driven:
8+
// no business knowledge (CLAUDE.md §7). The check is an inline SVG so
9+
// @dar/ui stays free of an icon-library dependency.
10+
11+
import type { InputHTMLAttributes } from 'react';
12+
13+
export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
14+
15+
export function Checkbox({ className = '', ...rest }: CheckboxProps) {
16+
return (
17+
<span className="relative inline-flex h-4 w-4 shrink-0 align-middle">
18+
<input
19+
type="checkbox"
20+
className={`peer h-4 w-4 cursor-pointer appearance-none rounded border border-gray-300 bg-transparent transition-colors checked:border-primary checked:bg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
21+
{...rest}
22+
/>
23+
{/* Tick shows only when the box is checked (peer-checked); white
24+
reads on the primary fill. pointer-events-none so the click
25+
falls through to the input underneath. */}
26+
<svg
27+
className="pointer-events-none absolute inset-0 m-auto hidden h-3 w-3 text-white peer-checked:block"
28+
viewBox="0 0 12 12"
29+
fill="none"
30+
aria-hidden
31+
>
32+
<path
33+
d="M2.5 6.5 5 9l4.5-5"
34+
stroke="currentColor"
35+
strokeWidth="2"
36+
strokeLinecap="round"
37+
strokeLinejoin="round"
38+
/>
39+
</svg>
40+
</span>
41+
);
42+
}

frontend/packages/ui/src/Table.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
66

7+
import { Checkbox } from './Checkbox';
78
import { Skeleton } from './Skeleton';
89

910
// Smallest a column can be dragged to — keeps a handle reachable.
@@ -161,8 +162,7 @@ export function Table<Row>({
161162
<tr>
162163
{selectable && (
163164
<th scope="col" className="w-10 px-4 py-2">
164-
<input
165-
type="checkbox"
165+
<Checkbox
166166
aria-label="Select all rows on this page"
167167
checked={allSelected}
168168
onChange={(e) => onToggleAll?.(e.target.checked)}
@@ -263,8 +263,7 @@ export function Table<Row>({
263263
>
264264
{selectable && (
265265
<td className="w-10 px-4 py-2" onClick={(e) => e.stopPropagation()}>
266-
<input
267-
type="checkbox"
266+
<Checkbox
268267
aria-label="Select row"
269268
checked={selected.has(key)}
270269
onChange={() => onToggleRow?.(key)}

frontend/packages/ui/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export type { TableColumn, TableProps } from './Table';
2626
export { Input } from './Input';
2727
export type { InputProps } from './Input';
2828

29+
export { Checkbox } from './Checkbox';
30+
export type { CheckboxProps } from './Checkbox';
31+
2932
export { Modal } from './Modal';
3033
export type { ModalProps } from './Modal';
3134

0 commit comments

Comments
 (0)