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
31 changes: 29 additions & 2 deletions src/components/ui/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,27 @@ export type FieldProps = {
* or an empty string when the field is fine.
*/
error?: ReactNode;
/** Appends the conventional asterisk and sets `required` semantics visually. */
/**
* Appends the conventional asterisk and carries the semantics through
* context, so the wrapped control also gets `aria-required` — the asterisk
* itself is `aria-hidden` and invisible to a screen reader.
*
* Presentation and ARIA, not constraint validation: this does **not** put the
* native `required` attribute on the wrapped control, so no `:required`
* styling and no browser "fill out this field" popup appear. The forms in
* this app validate themselves — guarded submit buttons, inline errors — and
* a label-level flag should not switch browser validation on behind their
* backs. Pass `required` to the control itself when the native attribute is
* wanted; the two stay separate on purpose.
*/
required?: boolean;
/**
* Appends a visible "(optional)" to the label, the counterpart for fields the
* form genuinely does not need. Rendered inside the `<label>` so it lands in
* the control's accessible name and announces with the field; ignored when
* `required` is also set, since the two are mutually exclusive in practice.
*/
optional?: boolean;
/**
* Fixed id for the control, when something outside the field has to point at
* it (`aria-controls`, a `ref`-free `focus()` by id, an existing E2E
Expand Down Expand Up @@ -60,6 +79,7 @@ export function Field({
hint,
error,
required = false,
optional = false,
controlId: providedControlId,
disabled = false,
className = "",
Expand All @@ -83,8 +103,9 @@ export function Field({
describedBy: described || undefined,
invalid: hasError,
disabled,
required,
};
}, [controlId, hintId, errorId, hasHint, hasError, disabled]);
}, [controlId, hintId, errorId, hasHint, hasError, disabled, required]);

return (
<div className={`flex flex-col gap-1.5 ${className}`.trim()}>
Expand All @@ -96,6 +117,12 @@ export function Field({
*
</span>
)}
{optional && !required && (
<>
{" "}
<span className="ml-1 text-xs font-normal text-app-text-subtle">(optional)</span>
</>
)}
</label>
)}

Expand Down
6 changes: 6 additions & 0 deletions src/components/ui/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
id,
disabled,
"aria-describedby": ariaDescribedBy,
"aria-required": ariaRequired,
...rest
},
ref,
Expand All @@ -74,6 +75,10 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
const resolvedId = id ?? field?.controlId;
const resolvedDescribedBy = ariaDescribedBy ?? field?.describedBy;
const isDisabled = disabled ?? field?.disabled ?? false;
// An explicit `aria-required` or HTML `required` on the control wins; only
// when neither is given does the enclosing `Field` supply it. Emitted only
// when true, so a plain input does not grow a stray `aria-required="false"`.
const isRequired = ariaRequired ?? rest.required ?? field?.required;

const control = (
<input
Expand All @@ -82,6 +87,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
disabled={isDisabled}
aria-invalid={isInvalid || undefined}
aria-describedby={resolvedDescribedBy}
aria-required={isRequired || undefined}
className={fieldClasses({
size,
invalid: isInvalid,
Expand Down
6 changes: 6 additions & 0 deletions src/components/ui/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,18 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
id,
disabled,
"aria-describedby": ariaDescribedBy,
"aria-required": ariaRequired,
children,
...rest
},
ref,
) {
const field = useContext(FieldContext);
const isInvalid = invalid ?? field?.invalid ?? false;
// An explicit `aria-required` or HTML `required` on the control wins; only
// when neither is given does the enclosing `Field` supply it. Emitted only
// when true, so a plain select does not grow a stray `aria-required="false"`.
const isRequired = ariaRequired ?? rest.required ?? field?.required;

return (
<select
Expand All @@ -44,6 +49,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
disabled={disabled ?? field?.disabled ?? false}
aria-invalid={isInvalid || undefined}
aria-describedby={ariaDescribedBy ?? field?.describedBy}
aria-required={isRequired || undefined}
className={fieldClasses({
size,
invalid: isInvalid,
Expand Down
6 changes: 6 additions & 0 deletions src/components/ui/Textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,17 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
disabled,
value,
"aria-describedby": ariaDescribedBy,
"aria-required": ariaRequired,
...rest
},
forwardedRef,
) {
const field = useContext(FieldContext);
const isInvalid = invalid ?? field?.invalid ?? false;
// An explicit `aria-required` or HTML `required` on the control wins; only
// when neither is given does the enclosing `Field` supply it. Emitted only
// when true, so a plain textarea does not grow a stray `aria-required="false"`.
const isRequired = ariaRequired ?? rest.required ?? field?.required;

const innerRef = useRef<HTMLTextAreaElement | null>(null);

Expand Down Expand Up @@ -107,6 +112,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
disabled={disabled ?? field?.disabled ?? false}
aria-invalid={isInvalid || undefined}
aria-describedby={ariaDescribedBy ?? field?.describedBy}
aria-required={isRequired || undefined}
className={textareaClasses({
invalid: isInvalid,
className: `${autoResize ? "resize-none overflow-hidden" : "resize-y"} ${className}`.trim(),
Expand Down
8 changes: 8 additions & 0 deletions src/components/ui/fieldContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ export type FieldContextValue = {
invalid: boolean;
/** Mirrors `Field`'s `disabled`, so the control does not need it passed twice. */
disabled: boolean;
/**
* Mirrors `Field`'s `required`, so the control can put `aria-required` on the
* real element — the asterisk in the label is `aria-hidden` decoration and
* tells assistive tech nothing on its own. The native `required` attribute is
* deliberately left to the caller: this flag is ARIA, not constraint
* validation.
*/
required: boolean;
};

/**
Expand Down
9 changes: 7 additions & 2 deletions src/features/admin/components/ProjectDetailsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,14 +331,19 @@ export function ProjectDetailsDrawer({
)}

<div className="space-y-4">
<Field label="Name" controlId={nameInputId} disabled={isSaving}>
<Field label="Name" controlId={nameInputId} required disabled={isSaving}>
<Input
value={draftProject.name}
onChange={(event) => updateDraftField("name", event.target.value)}
/>
</Field>

<Field label="Description" controlId={descriptionInputId} disabled={isSaving}>
<Field
label="Description"
controlId={descriptionInputId}
optional
disabled={isSaving}
>
<Textarea
value={draftProject.description}
onChange={(event) => updateDraftField("description", event.target.value)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function WizardDetailsStep({
/>
</Field>

<Field label="Description" controlId={descriptionInputId}>
<Field label="Description" controlId={descriptionInputId} optional>
<Textarea
value={description}
onChange={(event) => onDescriptionChange(event.target.value)}
Expand All @@ -102,7 +102,8 @@ export function WizardDetailsStep({
<Field
label="Industry"
controlId={industryInputId}
hint="Optional. Leave empty and set or evaluate it later from the project's admin drawer."
optional
hint="Leave empty and set or evaluate it later from the project's admin drawer."
>
<Input
value={industry}
Expand Down
7 changes: 4 additions & 3 deletions src/features/arrival/components/AddArrivalStepModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export function AddArrivalStepModal({

{phase === "details" && kind === "custom" && (
<div className="space-y-3">
<Field label="What needs to be done">
<Field label="What needs to be done" required>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
Expand All @@ -277,7 +277,8 @@ export function AddArrivalStepModal({

<Field
label="How to do it"
hint="Optional. Anything they need to know before starting."
optional
hint="Anything they need to know before starting."
>
<Textarea
value={description}
Expand All @@ -287,7 +288,7 @@ export function AddArrivalStepModal({
/>
</Field>

<Field label="Where to do it" hint="Optional link.">
<Field label="Where to do it" optional hint="Link to tool or docs.">
<Input
value={href}
onChange={(event) => setHref(event.target.value)}
Expand Down
4 changes: 2 additions & 2 deletions src/features/board/components/AddCardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function AddCardForm({ kind, onAdd, onClose }: AddCardFormProps) {
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 space-y-3">
{kind === "NOTE" && (
<Field label="Title (optional)" controlId={`${fieldId}-title`}>
<Field label="Title" optional controlId={`${fieldId}-title`}>
<Input
value={noteTitle}
onChange={(event) => setNoteTitle(event.target.value)}
Expand Down Expand Up @@ -155,7 +155,7 @@ export function AddCardForm({ kind, onAdd, onClose }: AddCardFormProps) {
</div>

{kind === "LINK" && (
<Field label="What to call it (optional)" controlId={`${fieldId}-label`}>
<Field label="What to call it" optional controlId={`${fieldId}-label`}>
<Input value={label} onChange={(event) => setLabel(event.target.value)} />
</Field>
)}
Expand Down
1 change: 1 addition & 0 deletions src/features/board/components/NewAreaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function NewAreaForm({ onCreate, onClose }: NewAreaFormProps) {
<Field
label="Name this area"
controlId="new-area-name"
required
hint="A part of the board you can open on its own — Paperwork, Week two, Before my first release."
>
<Input
Expand Down
2 changes: 1 addition & 1 deletion src/features/board/components/NoteCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export function NoteCard({ content, card, onDismiss, dismissing, onEdit, origin
>
{editing ? (
<div className="space-y-3">
<Field label="Title (optional)" controlId={`note-title-${card.id}`}>
<Field label="Title" optional controlId={`note-title-${card.id}`}>
<Input
value={titleDraft}
onChange={(event) => setTitleDraft(event.target.value)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function ConfluenceConnectStep({
</div>
)}

<Field label="Confluence base URL" controlId="confluence-base-url" disabled={isBusy}>
<Field label="Confluence base URL" controlId="confluence-base-url" required disabled={isBusy}>
<Input
data-testid="confluence-base-url"
type="url"
Expand All @@ -90,6 +90,7 @@ export function ConfluenceConnectStep({
<Field
label="Space ID"
controlId="confluence-space-id"
required
disabled={isBusy}
hint="Numeric ID of the space in Confluence Cloud."
>
Expand Down
6 changes: 3 additions & 3 deletions src/features/profile/components/AccountForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,15 @@ export function AccountForm({ profile, onUpdate }: AccountFormProps) {
className="space-y-4"
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field label="First Name" controlId="firstName">
<Field label="First Name" controlId="firstName" required>
<Input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
/>
</Field>
<Field label="Last Name" controlId="lastName">
<Field label="Last Name" controlId="lastName" required>
<Input
type="text"
value={lastName}
Expand All @@ -140,7 +140,7 @@ export function AccountForm({ profile, onUpdate }: AccountFormProps) {
</Field>
</div>

<Field label="Email Address" controlId="email">
<Field label="Email Address" controlId="email" required>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</Field>

Expand Down
3 changes: 2 additions & 1 deletion src/features/settings/components/TokenAddForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function TokenAddForm({ onClose, onSaved, embedded = false }: TokenAddFor
)}

<div className="space-y-3">
<Field label="Token name" controlId="settings-add-token-name" disabled={isSaving}>
<Field label="Token name" controlId="settings-add-token-name" required disabled={isSaving}>
<Input
ref={nameInputRef}
data-testid="settings-add-token-name"
Expand All @@ -125,6 +125,7 @@ export function TokenAddForm({ onClose, onSaved, embedded = false }: TokenAddFor
<Field
label="Token (ghp_...)"
controlId="settings-add-token-value"
required
hint="The token value is stored encrypted and cannot be retrieved after saving."
disabled={isSaving}
>
Expand Down
1 change: 1 addition & 0 deletions src/features/settings/components/TokenRotateForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export function TokenRotateForm({ name, onClose, onSaved }: TokenRotateFormProps
<Field
label="New GitHub PAT"
controlId="settings-rotate-token-value"
required
disabled={isSaving}
className="min-w-0 flex-1"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export function AtlassianCredentialAddForm({
<Field
label="Atlassian account email"
controlId="settings-atlassian-add-email"
required
disabled={isSaving}
>
<Input
Expand All @@ -140,7 +141,12 @@ export function AtlassianCredentialAddForm({
/>
</Field>

<Field label="Credential name" controlId="settings-atlassian-add-name" disabled={isSaving}>
<Field
label="Credential name"
controlId="settings-atlassian-add-name"
required
disabled={isSaving}
>
<Input
ref={nameInputRef}
data-testid="settings-atlassian-add-name"
Expand All @@ -155,6 +161,7 @@ export function AtlassianCredentialAddForm({
<Field
label="API token"
controlId="settings-atlassian-add-token"
required
disabled={isSaving}
hint="The token is stored encrypted and cannot be retrieved after saving."
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export function AtlassianCredentialRow({ credential, onSaved }: AtlassianCredent
<Field
label="New name"
controlId={`settings-atlassian-rename-${displayName}`}
required
disabled={isBusy}
className="min-w-0 flex-1"
>
Expand Down Expand Up @@ -248,6 +249,7 @@ export function AtlassianCredentialRow({ credential, onSaved }: AtlassianCredent
<Field
label="New API token"
controlId={`settings-atlassian-rotate-${displayName}`}
required
disabled={isBusy}
className="min-w-0 flex-1"
>
Expand Down
Loading
Loading