Skip to content

fix: remove fieldset/legend wrapping single-control form components - #40

Merged
ViewFromTheBox merged 2 commits into
release/1.0.1from
bugfix/39-input-fieldset-legend-a11y-fix
May 25, 2026
Merged

ViewFromTheBox merged 2 commits into
release/1.0.1from
bugfix/39-input-fieldset-legend-a11y-fix

Conversation

@ViewFromTheBox

@ViewFromTheBox ViewFromTheBox commented May 25, 2026 •

Copy link
Copy Markdown
Member

Description

The form components were wrapping each single labeled control in <fieldset>/<legend>, which is wrong semantics — <fieldset> is for groups of related controls (radio sets, checkbox groups, related questions), not lone inputs. This caused two real bugs downstream:

  1. Accessibility: screen readers announced each lone input as "group" (e.g. "Email, group, edit Email") instead of "Email, edit text". Legend re-announced on focus, adding AT verbosity.
  2. Styling: hosts without daisyUI's reset got the default browser fieldset border around every field. Verified in Keystone CMS consumer (artisanpack-ui/forms TextField → Input).

Fixed by switching every single-control form component to daisyUI v5's fieldset utility class on a plain <div> (not a <fieldset> element), paired with a <label class="fieldset-legend" htmlFor> and a presentational <span class="input"> wrapper. Inline mode on Input, Password, and DatePicker now uses daisyUI v5's floating-label pattern. RichTextEditor uses aria-labelledby since htmlFor doesn't bind to contenteditable divs.

Radio, Checkbox (group), and Toggle keep <fieldset>/<legend> since they're genuine control groups.

Closes: #39

Type of Change

  • Bug fix (fixes an issue)

Related Issue

Issue: #39

Motivation and Context

Reported via downstream Keystone CMS consumer — every contact-form field rendered as a fieldset-wrapped input, both ugly and inaccessible. Root cause was the v4-style markup left over from earlier work; daisyUI v5's .fieldset and .fieldset-legend are utility classes that work on any element, so we can keep the visual style while using semantically correct HTML.

Changes Made

  • Input, Textarea, Password, Pin, DatePicker, Select, RichTextEditor, Editor, File (both modes), ColorPicker, Range: replaced <fieldset class="fieldset"> + <legend class="fieldset-legend"> with <div class="fieldset"> + <label class="fieldset-legend" htmlFor>.
  • Input, Password, DatePicker: inline mode now wraps via <label class="floating-label"> (daisyUI v5 floating-label pattern); single-space placeholder injected when no caller placeholder is supplied so :placeholder-shown triggers correctly.
  • RichTextEditor: switched from htmlFor/aria-label to aria-labelledby pointing at the legend <span>, since <label htmlFor> does not associate with a contenteditable div.
  • Pin: dropped <fieldset> wrapper; the inner role="group" aria-label="PIN input" keeps the digits grouped accessibly.
  • DatePicker: extracted shared commonInputProps object to remove duplication between the adorned and bare input branches.
  • Added 17 regression tests across the affected components asserting no <fieldset>/<legend> wraps a single control and label↔input association is correct.

How Has This Been Tested?

Testing Environment:

  • Operating System: macOS
  • Browser (if applicable): Chrome
  • Project Version: @artisanpack-ui/react 1.0.1-rc

Tests Performed:

  1. npx vitest run — 702/702 pass
  2. npx tsc --noEmit -p packages/react/tsconfig.json — clean
  3. npx eslint packages/react/src — clean
  4. Verified visually in the Keystone CMS consumer: contact-form fields (text, email, textarea) now render with the label stacked above the input and no fieldset border in hosts without a daisyUI reset.

Accessibility Tests Run

  • Keyboard navigation tested
  • Screen reader tested
  • Color contrast verified (no color changes)
  • ARIA labels checked

Details: Verified that lone inputs no longer get announced with the "group" role; aria-describedby still wires hint/error nodes correctly; RichTextEditor's contenteditable is now properly labeled via aria-labelledby.

Tests Added

  • Unit tests added/updated
  • Integration tests added/updated
  • All tests passing

Test details: Added regression tests in Input, Textarea, Password, Pin, DatePicker, Select, RichTextEditor, Editor, File, ColorPicker, Range test files asserting no <fieldset>/<legend> wraps a single control. Added label↔input association tests (via getByLabelText / htmlFor / aria-labelledby) for the same set. Strengthened Input inline-mode test to assert the input is a descendant of the floating-label wrapper and carries the placeholder=" " trigger.

Documentation

  • Inline code documentation added
  • README updated
  • Wiki updated
  • API documentation updated

Documentation details: Updated the JSDoc on Input to document the v5 pattern (and to note that <fieldset>/<legend> are reserved for control groups). Added inline comments explaining the deliberate single-space placeholder used to trigger :placeholder-shown for floating-label CSS.

Pre-Submission Checklist

  • Followed contributing guidelines
  • Checked for other open PRs for same update
  • Code passes all tests
  • Code has been linted
  • Accessibility tests completed
  • Code follows project style guide
  • Self-review completed
  • Comments added for complex code
  • No new warnings generated

Summary by CodeRabbit

  • Refactor

    • Updated form field markup: replaced native fieldset/legend wrappers with div/label structures, standardized floating-label behavior and placeholder handling, and ensured clear/visibility toggle controls remain keyboard-focusable across form components.
  • Tests

    • Added/expanded tests to verify accessibility: label associations, correct id/htmlFor linkage, and that single inputs are not wrapped in fieldset/legend.

Review Change Stack

Single labeled inputs (text, email, textarea, select, etc.) were wrapping
each control in <fieldset>/<legend>, which is wrong semantics — fieldset is
for groups of related controls (radio sets, checkbox groups). Screen readers
announced lone inputs as "group" and hosts without a daisyUI reset got the
default browser fieldset border.

Switched Input, Textarea, Password, Pin, DatePicker, Select, RichTextEditor,
Editor, File, ColorPicker, and Range to render a <div class="fieldset">
+ <label class="fieldset-legend" htmlFor> + <span class="input"> pattern
(daisyUI v5 utility classes on semantic elements). Inline mode on Input,
Password, and DatePicker now uses daisyUI v5's floating-label pattern.
RichTextEditor uses aria-labelledby since htmlFor doesn't bind to
contenteditable divs. Radio, Checkbox group, and Toggle keep <fieldset>
since they're genuine control groups.

Added regression tests asserting no <fieldset>/<legend> wraps a single
control plus label↔input association for each affected component.

Closes #39
@coderabbitai

coderabbitai Bot commented May 25, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b0553574-e1c7-4358-ad82-eb034aa137a1

📥 Commits

Reviewing files that changed from the base of the PR and between de36a66 and 0812437.

📒 Files selected for processing (4)
  • packages/react/src/__tests__/form/Input.test.tsx
  • packages/react/src/__tests__/form/Password.test.tsx
  • packages/react/src/components/form/Input/Input.tsx
  • packages/react/src/components/form/Password/Password.tsx
💤 Files with no reviewable changes (2)
  • packages/react/src/components/form/Input/Input.tsx
  • packages/react/src/components/form/Password/Password.tsx

📝 Walkthrough

Walkthrough

This PR replaces single-control <fieldset>/<legend> wrappers with <div className="fieldset w-full"> and standalone <label className="fieldset-legend"> across form components, adds floating-label/inline branching for inputs, and expands tests to assert absence of fieldset/legend and correct label-to-control accessibility wiring.

Changes

Form Component Semantic Markup Refactoring

Layer / File(s) Summary
Test coverage: absence of fieldset/legend and label association
packages/react/src/__tests__/form/*.test.tsx
Adds tests verifying single controls no longer render fieldset/legend and that visible labels are associated to controls via htmlFor/id or aria-labelledby (RichTextEditor).
Simple wrapper & label replacements
packages/react/src/components/form/ColorPicker/*.tsx, Editor/*.tsx, Pin/*.tsx, Range/*.tsx, Textarea/*.tsx
Replace outer <fieldset> with <div className="fieldset w-full"> and render legend text as <label className="fieldset-legend"> while preserving required-asterisk and existing ARIA/hint/error wiring.
Input / DatePicker / Password floating-label and adornment refactor
packages/react/src/components/form/Input/*.tsx, DatePicker/*.tsx, Password/*.tsx
Introduce reusable wrappers (inputBox, commonInputProps, passwordBox), add inline (floating-label) vs non-inline branches, inject single-space placeholder for inline floating labels when appropriate, and relocate adornment/ARIA/hint/error rendering into the new structure.
File component dual-mode markup update
packages/react/src/components/form/File/*.tsx
Update both drag-drop and standard File render paths to use <div>/<label> instead of <fieldset>/<legend>, preserving styling and required indicators.
RichTextEditor aria-labelledby and wrapper change
packages/react/src/components/form/RichTextEditor/*.tsx
Compute labelId, render label in a separate <span id={labelId}>, switch editor accessible name from aria-label to aria-labelledby={labelId}, and replace fieldset with a div wrapper.
Select wrapper and label restructuring
packages/react/src/components/form/Select/*.tsx
Remove the outer label wrapper, render the label as a standalone label.fieldset-legend for non-inline mode, and group the select control and icons inside a span applying select/error styling.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

"🐰 I hopped through the JSX night,
swapping fieldsets for labels bright.
Floating placeholders learned to glow,
eleven inputs in tidy row.
Tests now check the ARIA light."

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: converting form components from fieldset/legend to div/label markup for single controls.
Linked Issues check ✅ Passed The PR fully addresses issue #39 by removing fieldset/legend from single-control form components and implementing canonical accessible markup with label-for-input associations.
Out of Scope Changes check ✅ Passed All changes are scoped to issue #39: form component markup refactoring and accessibility improvements. No unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bugfix/39-input-fieldset-legend-a11y-fix

Comment @coderabbitai help to get the list of available commands and usage tips.

@ViewFromTheBox

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ViewFromTheBox ViewFromTheBox moved this from Backlog to In Review in ArtisanPack UI Overview May 25, 2026
@ViewFromTheBox
ViewFromTheBox marked this pull request as ready for review May 25, 2026 21:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react/src/components/form/Input/Input.tsx`:
- Around line 128-135: The clear button in the Input component is rendered only
when clearable is true and currently sets tabIndex={-1}, removing it from
keyboard navigation; update the clear button (the element using onClear inside
the Input component) to be keyboard-focusable (remove tabIndex or set
tabIndex={0}) so keyboard users can reach and activate the clear action,
ensuring aria-label="Clear input" remains present for screen readers.

In `@packages/react/src/components/form/Password/Password.tsx`:
- Around line 130-148: The clear and visibility-toggle buttons (rendered when
clearable and !hideToggle) are removed from keyboard tab order by tabIndex={-1};
update the JSX for those buttons (the clear button that calls onClear and the
toggle button that calls setVisible and reads visible) to remove tabIndex or set
tabIndex={0} so they are keyboard-focusable, and ensure aria-labels remain
unchanged for screen reader clarity.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b85a3bff-a56a-4065-88b2-f89042b6d845

📥 Commits

Reviewing files that changed from the base of the PR and between c1c4cb6 and de36a66.

📒 Files selected for processing (22)
  • packages/react/src/__tests__/form/ColorPicker.test.tsx
  • packages/react/src/__tests__/form/DatePicker.test.tsx
  • packages/react/src/__tests__/form/Editor.test.tsx
  • packages/react/src/__tests__/form/File.test.tsx
  • packages/react/src/__tests__/form/Input.test.tsx
  • packages/react/src/__tests__/form/Password.test.tsx
  • packages/react/src/__tests__/form/Pin.test.tsx
  • packages/react/src/__tests__/form/Range.test.tsx
  • packages/react/src/__tests__/form/RichTextEditor.test.tsx
  • packages/react/src/__tests__/form/Select.test.tsx
  • packages/react/src/__tests__/form/Textarea.test.tsx
  • packages/react/src/components/form/ColorPicker/ColorPicker.tsx
  • packages/react/src/components/form/DatePicker/DatePicker.tsx
  • packages/react/src/components/form/Editor/Editor.tsx
  • packages/react/src/components/form/File/File.tsx
  • packages/react/src/components/form/Input/Input.tsx
  • packages/react/src/components/form/Password/Password.tsx
  • packages/react/src/components/form/Pin/Pin.tsx
  • packages/react/src/components/form/Range/Range.tsx
  • packages/react/src/components/form/RichTextEditor/RichTextEditor.tsx
  • packages/react/src/components/form/Select/Select.tsx
  • packages/react/src/components/form/Textarea/Textarea.tsx

Comment thread packages/react/src/components/form/Input/Input.tsx
Comment thread packages/react/src/components/form/Password/Password.tsx
Remove tabIndex={-1} from the clear button in Input and from the clear /
visibility-toggle buttons in Password. The buttons rely on aria-label for
screen reader naming, so removing them from the tab order made the actions
unreachable for keyboard users.

Surfaced by CodeRabbit during review of #39.
@ViewFromTheBox
ViewFromTheBox merged commit ca9f8e8 into release/1.0.1 May 25, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from In Review to Done in ArtisanPack UI Overview May 25, 2026
@ViewFromTheBox
ViewFromTheBox deleted the bugfix/39-input-fieldset-legend-a11y-fix branch May 25, 2026 22:04
@ViewFromTheBox ViewFromTheBox mentioned this pull request May 26, 2026
29 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

Input: <fieldset>/<legend> is wrong semantics + a11y issue for a single input

1 participant