Address PR review on data request pages
Require a verified viewer email before creating a rights request and validate the free-text fields with the same SafeText bounds the console uses, so this public portal mutation stays safe and bounded. Move myRightsRequests onto the base Query, drop the now-dead count loaders, and order the RECTIFICATION enum value before PORTABILITY so the Postgres sort order matches RightsRequestTypes(). Harden the v2 kit primitives: SegmentedControl keeps equal-width cards (auto-fill), preserves its selection when the active card is toggled, and forwards an accessible name; Field associates its label and error by id/aria instead of wrapping the control in a label. Give the type group an accessible name, require the name field for non-complaint types, use a timezone-stable reference year, drop the underreporting header count, and neutralize the response-deadline copy. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -19,40 +19,66 @@
|
||||
// SOFTWARE.
|
||||
|
||||
import { ToggleGroup as BaseToggleGroup } from "@base-ui/react/toggle-group";
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import { segmentedControl } from "./variants";
|
||||
|
||||
export type SegmentedControlProps = {
|
||||
// Single selected value (controlled).
|
||||
value?: string;
|
||||
"value"?: string;
|
||||
// Single selected value (uncontrolled).
|
||||
defaultValue?: string;
|
||||
"defaultValue"?: string;
|
||||
// Fired with the newly selected value. Never fired with an empty selection,
|
||||
// so a value always stays selected (clicking the active item is a no-op).
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
"onValueChange"?: (value: string) => void;
|
||||
"disabled"?: boolean;
|
||||
"className"?: string;
|
||||
// Accessible name for the group (or reference a visible label via
|
||||
// `aria-labelledby`), since the control has no intrinsic label.
|
||||
"aria-label"?: string;
|
||||
"aria-labelledby"?: string;
|
||||
"children"?: ReactNode;
|
||||
};
|
||||
|
||||
// Single-select pill group. Wraps Base UI's array-based ToggleGroup with a
|
||||
// friendlier single-value API.
|
||||
// friendlier single-value API. Selection is tracked internally (seeded from
|
||||
// `value`/`defaultValue`) so toggling the active item off — which Base UI
|
||||
// reports as an empty group value — never clears the selection.
|
||||
export function SegmentedControl(props: SegmentedControlProps) {
|
||||
const { value, defaultValue, onValueChange, disabled, className, children } = props;
|
||||
const {
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
"aria-label": ariaLabel,
|
||||
"aria-labelledby": ariaLabelledby,
|
||||
} = props;
|
||||
const { root } = segmentedControl();
|
||||
|
||||
const isControlled = value !== undefined;
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
const currentValue = isControlled ? value : internalValue;
|
||||
|
||||
return (
|
||||
<BaseToggleGroup
|
||||
className={root({ className })}
|
||||
disabled={disabled}
|
||||
value={value != null ? [value] : undefined}
|
||||
defaultValue={defaultValue != null ? [defaultValue] : undefined}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
value={currentValue != null ? [currentValue] : []}
|
||||
onValueChange={(groupValue) => {
|
||||
const next = groupValue[0];
|
||||
if (next != null) {
|
||||
onValueChange?.(next);
|
||||
// Ignore the empty value emitted when the active item is toggled off,
|
||||
// preserving the single-selection contract.
|
||||
if (next == null) {
|
||||
return;
|
||||
}
|
||||
if (!isControlled) {
|
||||
setInternalValue(next);
|
||||
}
|
||||
onValueChange?.(next);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { tv } from "tailwind-variants/lite";
|
||||
// (the pressed state only darkens the border, so selection never shifts layout).
|
||||
export const segmentedControl = tv({
|
||||
slots: {
|
||||
root: "grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-1",
|
||||
root: "grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-1",
|
||||
item: [
|
||||
"min-w-0 cursor-pointer select-none rounded-3 border border-sand-a6 bg-sand-1 px-4 py-3.5",
|
||||
"text-center text-2 font-medium text-sand-12 outline-none transition-colors",
|
||||
|
||||
@@ -18,32 +18,60 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cloneElement, isValidElement, type ReactElement, type ReactNode, useId } from "react";
|
||||
|
||||
import { field } from "./variants";
|
||||
|
||||
export type FieldProps = {
|
||||
// Text shown above the control. The control is nested inside the <label> so
|
||||
// the association is implicit (no htmlFor / id threading required).
|
||||
// Text shown above the control, associated with it via `htmlFor`/`id`.
|
||||
label?: ReactNode;
|
||||
// Validation / server error shown below the control.
|
||||
// Validation / server error shown below the control and linked to it via
|
||||
// `aria-describedby` so assistive technology announces it.
|
||||
error?: ReactNode;
|
||||
className?: string;
|
||||
// A single form control (TextField, Textarea, …). It receives an injected
|
||||
// `id`, plus `aria-describedby`/`aria-invalid` when an error is present.
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// Vertical label + control + error grouping for form dialogs.
|
||||
// Vertical label + control + error grouping for form dialogs. Unlike a raw
|
||||
// <label> wrapper, the label associates with the control by id, so controls
|
||||
// whose root is a <div> (and multi-element controls) remain valid and clicks
|
||||
// never activate an unintended descendant.
|
||||
export function Field(props: FieldProps) {
|
||||
const { label, error, className, children } = props;
|
||||
const { root, label: labelSlot, labelText, error: errorSlot } = field();
|
||||
const { root, labelText, error: errorSlot } = field();
|
||||
|
||||
const generatedId = useId();
|
||||
const errorId = useId();
|
||||
|
||||
const child = isValidElement(children)
|
||||
? (children as ReactElement<Record<string, unknown>>)
|
||||
: null;
|
||||
const existingId = typeof child?.props.id === "string" ? child.props.id : undefined;
|
||||
const controlId = existingId ?? generatedId;
|
||||
|
||||
const control = child
|
||||
? cloneElement(child, {
|
||||
"id": controlId,
|
||||
"aria-describedby": error != null ? errorId : undefined,
|
||||
"aria-invalid": error != null ? true : undefined,
|
||||
})
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div className={root({ className })}>
|
||||
<label className={labelSlot()}>
|
||||
{label != null && <span className={labelText()}>{label}</span>}
|
||||
{children}
|
||||
</label>
|
||||
{error != null && <p className={errorSlot()}>{error}</p>}
|
||||
{label != null && (
|
||||
<label htmlFor={controlId} className={labelText()}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{control}
|
||||
{error != null && (
|
||||
<p id={errorId} className={errorSlot()}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ export const textArea = tv({
|
||||
export const field = tv({
|
||||
slots: {
|
||||
root: "flex flex-col gap-1.5",
|
||||
label: "flex flex-col gap-1.5",
|
||||
labelText: "text-2 font-medium text-sand-12",
|
||||
error: "text-1 text-red-a11",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user