Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/field-drop-group-role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@surfnet/curve-react': patch
'@surfnet/curve-angular': patch
---

`Field` / `hlmField` no longer renders `role="group"`. A single field isn't a group, and inside a choice-card label the role caused the radio or checkbox to lose its accessible name (axe "Form elements must have labels"). To group related fields, use `FieldSet` / `fieldset[hlmFieldSet]`.
5 changes: 5 additions & 0 deletions .changeset/radio-group-choice-card.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-react': minor
---

`FieldLabel` now becomes a selectable "choice card" when it wraps a `Field` (bordered, with a primary tint when the control inside is checked), matching `@surfnet/curve-angular`. Horizontal `Field`s with `FieldContent` now top-align their checkbox or radio. See the new RadioGroup **ChoiceCard** story.
2 changes: 1 addition & 1 deletion packages/angular/src/lib/ui/field/src/lib/hlm-field.css
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
.curve-field-label:has(> [data-slot='field']) {
width: 100%;
flex-direction: column;
border: 1px solid var(--border);
border: 1px solid var(--input);
border-radius: var(--radius-md, calc(var(--radius, 0.625rem) * 0.8));
}

Expand Down
3 changes: 2 additions & 1 deletion packages/angular/src/lib/ui/field/src/lib/hlm-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ export type FieldVariants = { orientation?: FieldOrientationName | null };
@Directive({
selector: '[hlmField],hlm-field',
hostDirectives: [{ directive: BrnField, inputs: ['data-invalid', 'forceInvalid'] }],
// CURVE: a11y — no role="group" (upstream adds it): a single field isn't a group, and inside a
// choice-card label it blanks the label's text for axe. Group fields with hlmFieldSet instead.
host: {
role: 'group',
'data-slot': 'field',
'[attr.data-orientation]': 'orientation()',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,22 @@
}

.curve-radio-indicator-dot {
position: absolute;
top: 50%;
left: 50%;
width: calc(var(--spacing, 0.25rem) * 2);
height: calc(var(--spacing, 0.25rem) * 2);
border-radius: calc(infinity * 1px);
border-radius: 9999px;
transform: translate(-50%, -50%);
background-color: transparent;
}

.curve-radio[data-checked='true'] .curve-radio-indicator-dot {
.brn-radio-checked .curve-radio-indicator {
border-color: var(--primary);
background-color: var(--primary);
color: var(--primary-foreground);
}

.curve-radio[data-checked='true'] .curve-radio-indicator-dot {
background-color: var(--background);
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,71 @@ export const InFieldset: Story = {
}),
};

/**
* Choice cards: wrap each option's `hlmField` in a `label[hlmFieldLabel]` and the whole
* label becomes a selectable card. The selected card gets a primary-tinted border and
* background. The group is named by a visible `label[hlmLabel]` via `aria-labelledby`.
*/
export const ChoiceCard: Story = {
render: () => ({
template: `
<div class="flex max-w-sm flex-col gap-3">
<label hlmLabel id="plan-label">Plan</label>
<hlm-radio-group value="plus" aria-labelledby="plan-label">
<label hlmFieldLabel for="plus-plan">
<div hlmField orientation="horizontal">
<div hlmFieldContent>
<div hlmFieldTitle id="plus-plan-title">Plus</div>
<p hlmFieldDescription id="plus-plan-description">For individuals and small teams.</p>
</div>
<hlm-radio
value="plus"
inputId="plus-plan"
aria-labelledby="plus-plan-title"
aria-describedby="plus-plan-description"
>
<hlm-radio-indicator indicator />
</hlm-radio>
</div>
</label>
<label hlmFieldLabel for="pro-plan">
<div hlmField orientation="horizontal">
<div hlmFieldContent>
<div hlmFieldTitle id="pro-plan-title">Pro</div>
<p hlmFieldDescription id="pro-plan-description">For growing businesses.</p>
</div>
<hlm-radio
value="pro"
inputId="pro-plan"
aria-labelledby="pro-plan-title"
aria-describedby="pro-plan-description"
>
<hlm-radio-indicator indicator />
</hlm-radio>
</div>
</label>
<label hlmFieldLabel for="enterprise-plan">
<div hlmField orientation="horizontal">
<div hlmFieldContent>
<div hlmFieldTitle id="enterprise-plan-title">Enterprise</div>
<p hlmFieldDescription id="enterprise-plan-description">For large teams and enterprises.</p>
</div>
<hlm-radio
value="enterprise"
inputId="enterprise-plan"
aria-labelledby="enterprise-plan-title"
aria-describedby="enterprise-plan-description"
>
<hlm-radio-indicator indicator />
</hlm-radio>
</div>
</label>
</hlm-radio-group>
</div>
`,
}),
};

/** Disabling a single item versus disabling the whole group. */
export const Disabled: Story = {
render: () => ({
Expand Down
32 changes: 32 additions & 0 deletions packages/react/src/components/ui/field/field.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@
align-items: center;
}

.field[data-orientation='horizontal']:has(> [data-slot='field-content']) {
align-items: flex-start;
}

.field[data-orientation='horizontal']:has(> [data-slot='field-content']) > [role='checkbox'],
.field[data-orientation='horizontal']:has(> [data-slot='field-content']) [role='radio'] {
margin-top: 1px;
}

.fieldContent {
display: flex;
flex: 1;
Expand All @@ -74,6 +83,29 @@
line-height: 1.375;
}

.fieldLabel:has(:is([data-state='checked'], [data-checked]:not([data-checked='false']))) {
border-color: color-mix(in oklab, var(--primary) 30%, transparent);
background-color: color-mix(in oklab, var(--primary) 5%, transparent);
}

:global(.dark)
.fieldLabel:has(:is([data-state='checked'], [data-checked]:not([data-checked='false']))) {
border-color: color-mix(in oklab, var(--primary) 20%, transparent);
background-color: color-mix(in oklab, var(--primary) 10%, transparent);
}

/* A label wrapping a whole field becomes a selectable card. */
.fieldLabel:has(> [data-slot='field']) {
width: 100%;
flex-direction: column;
border: 1px solid var(--input);
border-radius: var(--radius-md, calc(var(--radius, 0.625rem) * 0.8));
}

.fieldLabel > [data-slot='field'] {
padding: calc(var(--spacing, 0.25rem) * 3);
}

.fieldTitle {
display: flex;
width: fit-content;
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/components/ui/field/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ function Field({
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & FieldVariantsOptions) {
// CURVE: a11y — no role="group" (upstream adds it): a single field isn't a group, and inside a
// choice-card label it blanks the label's text for axe. Group fields with FieldSet instead.
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={fieldVariants({ orientation, className })}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,6 @@
width: calc(var(--spacing, 0.25rem) * 2);
height: calc(var(--spacing, 0.25rem) * 2);
border-radius: 9999px;
background-color: var(--primary-foreground);
background-color: var(--background);
transform: translate(-50%, -50%);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { radioGroupContract } from '@surfnet/curve-contracts';

import { FieldDescription, FieldLabel, FieldLegend, FieldSet } from '@/components/ui/field';
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from '@/components/ui/field';
import { Label } from '@/components/ui/label';

import { RadioGroup, RadioGroupItem } from './radio-group';
Expand Down Expand Up @@ -83,6 +91,67 @@ export const InFieldset: Story = {
),
};

/**
* Choice cards: wrap each option's `Field` in a `FieldLabel` and the whole label becomes a
* selectable card. The selected card gets a primary-tinted border and background. The group
* is named by a visible `Label` via `aria-labelledby`.
*/
export const ChoiceCard: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-3">
<Label id="plan-label">Plan</Label>
<RadioGroup defaultValue="plus" aria-labelledby="plan-label">
<FieldLabel htmlFor="plus-plan">
<Field orientation="horizontal">
<FieldContent>
<FieldTitle id="plus-plan-title">Plus</FieldTitle>
<FieldDescription id="plus-plan-description">
For individuals and small teams.
</FieldDescription>
</FieldContent>
<RadioGroupItem
value="plus"
id="plus-plan"
aria-labelledby="plus-plan-title"
aria-describedby="plus-plan-description"
/>
</Field>
</FieldLabel>
<FieldLabel htmlFor="pro-plan">
<Field orientation="horizontal">
<FieldContent>
<FieldTitle id="pro-plan-title">Pro</FieldTitle>
<FieldDescription id="pro-plan-description">For growing businesses.</FieldDescription>
</FieldContent>
<RadioGroupItem
value="pro"
id="pro-plan"
aria-labelledby="pro-plan-title"
aria-describedby="pro-plan-description"
/>
</Field>
</FieldLabel>
<FieldLabel htmlFor="enterprise-plan">
<Field orientation="horizontal">
<FieldContent>
<FieldTitle id="enterprise-plan-title">Enterprise</FieldTitle>
<FieldDescription id="enterprise-plan-description">
For large teams and enterprises.
</FieldDescription>
</FieldContent>
<RadioGroupItem
value="enterprise"
id="enterprise-plan"
aria-labelledby="enterprise-plan-title"
aria-describedby="enterprise-plan-description"
/>
</Field>
</FieldLabel>
</RadioGroup>
</div>
),
};

/** Disabling a single item versus disabling the whole group. */
export const Disabled: Story = {
render: () => (
Expand Down
Loading