@object-ui/fields 17.5.0 → 17.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +714 -0
- package/dist/FieldEditWidget.d.ts +16 -2
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +4 -4
- package/dist/index.css +27 -9
- package/dist/index.d.ts +425 -72
- package/dist/index.js +2530 -2228
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/BooleanField.d.ts +1 -1
- package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/DateField.d.ts +1 -1
- package/dist/widgets/DateTimeField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +28 -1
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +56 -4
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +13 -7
- package/dist/widgets/PasswordField.d.ts +1 -1
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +2 -4
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- package/dist/widgets/SignatureField.d.ts +32 -2
- package/dist/widgets/SliderField.d.ts +37 -2
- package/dist/widgets/SummaryField.d.ts +1 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +6 -2
- package/dist/widgets/TextField.d.ts +1 -1
- package/dist/widgets/TimeField.d.ts +1 -1
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/UserField.d.ts +1 -1
- package/dist/widgets/VectorField.d.ts +1 -1
- package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +27 -7
- package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
- package/dist/widgets/richTextDisplay.d.ts +80 -0
- package/dist/widgets/toHostGroupProps.d.ts +119 -21
- package/dist/widgets/types.d.ts +8 -3
- package/package.json +9 -10
- package/dist/widgets/CharacterCount.d.ts +0 -56
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -207
- package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
- package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
- package/src/__tests__/DateCellRenderer.test.tsx +0 -82
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
- package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
- package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
- package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
- package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
- package/src/__tests__/date-locale-channel.test.tsx +0 -305
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
- package/src/__tests__/group-labelling-declaration.test.ts +0 -148
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
- package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
- package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
- package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
- package/src/__tests__/validation-feedback.test.tsx +0 -361
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
- package/src/__tests__/widget-props-contract.test.tsx +0 -142
- package/src/coerce-safe-value.test.ts +0 -34
- package/src/complex-widgets.test.tsx +0 -924
- package/src/currency.ts +0 -144
- package/src/datetime-widgets.test.tsx +0 -185
- package/src/field-type-alias.multiple.test.ts +0 -81
- package/src/field-type-alias.ts +0 -156
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.css +0 -44
- package/src/index.tsx +0 -2745
- package/src/standard-widgets.test.tsx +0 -364
- package/src/widgets/AddressField.postalCode.test.tsx +0 -257
- package/src/widgets/AddressField.tsx +0 -182
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -86
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -169
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -135
- package/src/widgets/DateField.tsx +0 -40
- package/src/widgets/DateTimeField.tsx +0 -46
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -512
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -38
- package/src/widgets/FullscreenFieldEditor.tsx +0 -60
- package/src/widgets/GeolocationField.tsx +0 -193
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -632
- package/src/widgets/GridField.tsx +0 -1070
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.maxSize.test.tsx +0 -154
- package/src/widgets/ImageField.tsx +0 -305
- package/src/widgets/ImageLightbox.test.tsx +0 -52
- package/src/widgets/ImageLightbox.tsx +0 -108
- package/src/widgets/LocationField.tsx +0 -57
- package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
- package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
- package/src/widgets/LookupField.hydration.test.tsx +0 -136
- package/src/widgets/LookupField.idField.test.tsx +0 -61
- package/src/widgets/LookupField.tsx +0 -1295
- package/src/widgets/MarkdownContent.tsx +0 -19
- package/src/widgets/MasterDetailField.tsx +0 -131
- package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
- package/src/widgets/MultiSelectField.tsx +0 -196
- package/src/widgets/NumberField.tsx +0 -49
- package/src/widgets/ObjectField.tsx +0 -94
- package/src/widgets/ObjectRefField.tsx +0 -121
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
- package/src/widgets/OptionsEmptyState.test.tsx +0 -169
- package/src/widgets/OptionsEmptyState.tsx +0 -99
- package/src/widgets/PasswordField.tsx +0 -49
- package/src/widgets/PeoplePicker.test.tsx +0 -360
- package/src/widgets/PeoplePicker.tsx +0 -579
- package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
- package/src/widgets/PercentField.tsx +0 -95
- package/src/widgets/PersonRow.tsx +0 -117
- package/src/widgets/PhoneField.tsx +0 -37
- package/src/widgets/QRCodeField.tsx +0 -103
- package/src/widgets/RadioField.cascade.test.tsx +0 -141
- package/src/widgets/RadioField.tsx +0 -143
- package/src/widgets/RatingField.tsx +0 -94
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1365
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -193
- package/src/widgets/SelectionTray.tsx +0 -110
- package/src/widgets/SignatureField.tsx +0 -140
- package/src/widgets/SliderField.tsx +0 -39
- package/src/widgets/SummaryField.tsx +0 -31
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
- package/src/widgets/TagsField.placeholder.test.tsx +0 -95
- package/src/widgets/TagsField.tsx +0 -88
- package/src/widgets/TextAreaField.tsx +0 -213
- package/src/widgets/TextField.tsx +0 -45
- package/src/widgets/TimeField.tsx +0 -31
- package/src/widgets/UrlField.tsx +0 -46
- package/src/widgets/UserField.tsx +0 -59
- package/src/widgets/UserFieldPicker.test.tsx +0 -78
- package/src/widgets/VectorField.tsx +0 -29
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
- package/src/widgets/_cell-renderer-bridge.ts +0 -19
- package/src/widgets/address-format.ts +0 -120
- package/src/widgets/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-size-guard.ts +0 -55
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/nativeDateValue.test.ts +0 -115
- package/src/widgets/nativeDateValue.ts +0 -112
- package/src/widgets/openNativePicker.ts +0 -18
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
- package/src/widgets/personDisplay.test.ts +0 -113
- package/src/widgets/personDisplay.ts +0 -92
- package/src/widgets/recentLookups.test.ts +0 -45
- package/src/widgets/recentLookups.ts +0 -47
- package/src/widgets/toDomProps.ts +0 -204
- package/src/widgets/toHostGroupProps.ts +0 -102
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -132
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -291
- package/src/widgets/useUploadingSignal.test.tsx +0 -62
- package/src/widgets/useUploadingSignal.ts +0 -20
- package/src/withFieldCarrier.tsx +0 -75
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Input, Slider, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { FieldWidgetComponentProps } from './types';
|
|
4
|
-
import { toDomProps } from './toDomProps';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* PercentField - Percentage input with configurable decimal precision
|
|
8
|
-
* Stores values as decimals (0-1) and displays as percentages (0-100%)
|
|
9
|
-
* Includes a slider for interactive control.
|
|
10
|
-
*/
|
|
11
|
-
export function PercentField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<number>) {
|
|
12
|
-
const percentField = field as any;
|
|
13
|
-
const precision = percentField?.precision ?? 2;
|
|
14
|
-
|
|
15
|
-
// Convention detection. A field declaring `max > 1` (e.g. `max: 100`) stores
|
|
16
|
-
// WHOLE-NUMBER percents (0–100); otherwise values are FRACTIONS (0–1) shown
|
|
17
|
-
// as 0–100%. This matches the read-side formatter so the edit widget agrees
|
|
18
|
-
// with display — and, crucially, keeps the rendered <input> within its `max`
|
|
19
|
-
// (a whole-number 50 must show "50", not "5000", or HTML5 constraint
|
|
20
|
-
// validation marks the field `:invalid` and blocks the whole form's submit).
|
|
21
|
-
const maxAttr = typeof percentField?.max === 'number' ? (percentField.max as number) : undefined;
|
|
22
|
-
const whole = maxAttr != null && maxAttr > 1;
|
|
23
|
-
const toDisplay = (v: number) => (whole ? v : v * 100);
|
|
24
|
-
const fromDisplay = (n: number) => (whole ? n : n / 100);
|
|
25
|
-
const sliderMax = whole ? maxAttr! : 100;
|
|
26
|
-
|
|
27
|
-
if (readonly) {
|
|
28
|
-
if (value == null) return <EmptyValue />;
|
|
29
|
-
return (
|
|
30
|
-
<span className="text-sm font-medium tabular-nums">
|
|
31
|
-
{toDisplay(value).toFixed(precision)}%
|
|
32
|
-
</span>
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Convert between stored value and 0–100 display value
|
|
37
|
-
const displayValue = value != null ? toDisplay(value) : '';
|
|
38
|
-
const sliderValue = value != null ? toDisplay(value) : 0;
|
|
39
|
-
|
|
40
|
-
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
41
|
-
if (e.target.value === '') {
|
|
42
|
-
onChange(null as any);
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
const parsed = parseFloat(e.target.value);
|
|
46
|
-
const val = isNaN(parsed) ? null : fromDisplay(parsed);
|
|
47
|
-
onChange(val as any);
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const handleSliderChange = (values: number[]) => {
|
|
51
|
-
if (readonly || props.disabled) return;
|
|
52
|
-
if (!Array.isArray(values) || values.length === 0) {
|
|
53
|
-
onChange(null as any);
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
const raw = values[0];
|
|
57
|
-
const nextValue = typeof raw === 'number' ? fromDisplay(raw) : null;
|
|
58
|
-
onChange(nextValue as any);
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
// Derive slider step from precision so slider granularity matches the input
|
|
62
|
-
const sliderStep = Math.pow(10, -precision);
|
|
63
|
-
|
|
64
|
-
return (
|
|
65
|
-
<div className="space-y-2">
|
|
66
|
-
<div className="relative">
|
|
67
|
-
<Input
|
|
68
|
-
{...toDomProps(props)}
|
|
69
|
-
type="number"
|
|
70
|
-
value={displayValue}
|
|
71
|
-
onChange={handleChange}
|
|
72
|
-
placeholder={percentField?.placeholder || '0'}
|
|
73
|
-
disabled={readonly || props.disabled}
|
|
74
|
-
className={`pr-8 ${className || ''}`}
|
|
75
|
-
step={Math.pow(10, -precision).toFixed(precision)}
|
|
76
|
-
aria-invalid={!!error}
|
|
77
|
-
/>
|
|
78
|
-
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
|
|
79
|
-
%
|
|
80
|
-
</span>
|
|
81
|
-
</div>
|
|
82
|
-
<Slider
|
|
83
|
-
value={[sliderValue]}
|
|
84
|
-
onValueChange={handleSliderChange}
|
|
85
|
-
min={0}
|
|
86
|
-
max={sliderMax}
|
|
87
|
-
step={sliderStep}
|
|
88
|
-
disabled={readonly || props.disabled}
|
|
89
|
-
className="w-full"
|
|
90
|
-
aria-label="Percentage"
|
|
91
|
-
data-testid="percent-slider"
|
|
92
|
-
/>
|
|
93
|
-
</div>
|
|
94
|
-
);
|
|
95
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import React, { useEffect, useRef } from 'react';
|
|
10
|
-
import { Avatar, AvatarFallback, AvatarImage, cn } from '@object-ui/components';
|
|
11
|
-
import { Check } from 'lucide-react';
|
|
12
|
-
import {
|
|
13
|
-
getPersonName,
|
|
14
|
-
getPersonInitials,
|
|
15
|
-
getPersonSubtitle,
|
|
16
|
-
getPersonAvatarUrl,
|
|
17
|
-
matchRanges,
|
|
18
|
-
} from './personDisplay';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* A rich, single-line candidate row for the search-first PeoplePicker:
|
|
22
|
-
* avatar (image with initials fallback) + name + subtitle (department · email).
|
|
23
|
-
* The subtitle is what lets search stand in for an org tree — it disambiguates
|
|
24
|
-
* same-named people inline. The typed query is highlighted in both lines.
|
|
25
|
-
* Selectable/toggleable; a Check marks the selected state, and `active` reflects
|
|
26
|
-
* the keyboard cursor (scrolls itself into view).
|
|
27
|
-
*/
|
|
28
|
-
export interface PersonRowProps {
|
|
29
|
-
record: any;
|
|
30
|
-
displayField?: string;
|
|
31
|
-
subtitleFields?: string[];
|
|
32
|
-
avatarField?: string;
|
|
33
|
-
selected?: boolean;
|
|
34
|
-
/** Keyboard cursor is on this row — highlight + scroll into view. */
|
|
35
|
-
active?: boolean;
|
|
36
|
-
/** Typed search term; matches are highlighted in name + subtitle. */
|
|
37
|
-
highlightQuery?: string;
|
|
38
|
-
onSelect?: (record: any) => void;
|
|
39
|
-
className?: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Wrap literal matches of `query` in a subtle highlight. */
|
|
43
|
-
function Highlighted({ text, query }: { text: string; query?: string }): React.ReactElement {
|
|
44
|
-
const ranges = query ? matchRanges(text, query) : [];
|
|
45
|
-
if (ranges.length === 0) return <>{text}</>;
|
|
46
|
-
const out: React.ReactNode[] = [];
|
|
47
|
-
let cursor = 0;
|
|
48
|
-
ranges.forEach(([start, end], i) => {
|
|
49
|
-
if (start > cursor) out.push(text.slice(cursor, start));
|
|
50
|
-
out.push(
|
|
51
|
-
<mark key={i} className="rounded-[2px] bg-primary/20 px-px text-inherit">
|
|
52
|
-
{text.slice(start, end)}
|
|
53
|
-
</mark>,
|
|
54
|
-
);
|
|
55
|
-
cursor = end;
|
|
56
|
-
});
|
|
57
|
-
if (cursor < text.length) out.push(text.slice(cursor));
|
|
58
|
-
return <>{out}</>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function PersonRow({
|
|
62
|
-
record,
|
|
63
|
-
displayField = 'name',
|
|
64
|
-
subtitleFields,
|
|
65
|
-
avatarField = 'image',
|
|
66
|
-
selected = false,
|
|
67
|
-
active = false,
|
|
68
|
-
highlightQuery,
|
|
69
|
-
onSelect,
|
|
70
|
-
className,
|
|
71
|
-
}: PersonRowProps) {
|
|
72
|
-
const name = getPersonName(record, displayField);
|
|
73
|
-
const subtitle = getPersonSubtitle(record, subtitleFields);
|
|
74
|
-
const avatarUrl = getPersonAvatarUrl(record, avatarField);
|
|
75
|
-
const initials = getPersonInitials(name);
|
|
76
|
-
|
|
77
|
-
const ref = useRef<HTMLButtonElement>(null);
|
|
78
|
-
useEffect(() => {
|
|
79
|
-
// Optional-call the method too — jsdom doesn't implement scrollIntoView.
|
|
80
|
-
if (active) ref.current?.scrollIntoView?.({ block: 'nearest' });
|
|
81
|
-
}, [active]);
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
<button
|
|
85
|
-
ref={ref}
|
|
86
|
-
type="button"
|
|
87
|
-
tabIndex={-1}
|
|
88
|
-
onClick={() => onSelect?.(record)}
|
|
89
|
-
aria-pressed={selected}
|
|
90
|
-
data-testid="person-row"
|
|
91
|
-
data-active={active || undefined}
|
|
92
|
-
className={cn(
|
|
93
|
-
'flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left transition-colors',
|
|
94
|
-
'hover:bg-accent focus-visible:outline-none',
|
|
95
|
-
selected && 'bg-accent',
|
|
96
|
-
active && 'bg-accent ring-1 ring-inset ring-ring',
|
|
97
|
-
className,
|
|
98
|
-
)}
|
|
99
|
-
>
|
|
100
|
-
<Avatar className="size-9 shrink-0">
|
|
101
|
-
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
|
|
102
|
-
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
|
103
|
-
</Avatar>
|
|
104
|
-
<div className="min-w-0 flex-1">
|
|
105
|
-
<div className="truncate text-sm font-medium">
|
|
106
|
-
{name ? <Highlighted text={name} query={highlightQuery} /> : '—'}
|
|
107
|
-
</div>
|
|
108
|
-
{subtitle && (
|
|
109
|
-
<div className="truncate text-xs text-muted-foreground">
|
|
110
|
-
<Highlighted text={subtitle} query={highlightQuery} />
|
|
111
|
-
</div>
|
|
112
|
-
)}
|
|
113
|
-
</div>
|
|
114
|
-
{selected && <Check className="size-4 shrink-0 text-primary" aria-hidden />}
|
|
115
|
-
</button>
|
|
116
|
-
);
|
|
117
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { FieldWidgetComponentProps } from './types';
|
|
4
|
-
import { toDomProps } from './toDomProps';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* PhoneField - Telephone number input with tel link in readonly mode
|
|
8
|
-
* Renders as a clickable tel link when readonly, standard phone input otherwise
|
|
9
|
-
*/
|
|
10
|
-
export function PhoneField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
|
-
const config = field;
|
|
12
|
-
if (readonly) {
|
|
13
|
-
if (!value) return <EmptyValue />;
|
|
14
|
-
return (
|
|
15
|
-
<a
|
|
16
|
-
href={`tel:${value}`}
|
|
17
|
-
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
|
18
|
-
>
|
|
19
|
-
{value}
|
|
20
|
-
</a>
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const domProps = toDomProps(props);
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<Input
|
|
28
|
-
{...domProps}
|
|
29
|
-
type="tel"
|
|
30
|
-
value={value || ''}
|
|
31
|
-
onChange={(e) => onChange(e.target.value)}
|
|
32
|
-
placeholder={config?.placeholder || '(555) 123-4567'}
|
|
33
|
-
disabled={readonly || domProps.disabled}
|
|
34
|
-
aria-invalid={!!error}
|
|
35
|
-
/>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Input, Button, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { QrCode, Copy } from 'lucide-react';
|
|
4
|
-
import { FieldWidgetComponentProps } from './types';
|
|
5
|
-
import { toDomProps } from './toDomProps';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* QR Code field widget - generates QR codes from text
|
|
9
|
-
* Uses a simple SVG-based QR code generator
|
|
10
|
-
*/
|
|
11
|
-
export function QRCodeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
12
|
-
const [showQR, setShowQR] = React.useState(false);
|
|
13
|
-
const config = field;
|
|
14
|
-
|
|
15
|
-
// Simple QR code generation using an external library would be ideal
|
|
16
|
-
// For now, we'll use a service API approach or placeholder
|
|
17
|
-
const getQRCodeUrl = (text: string): string => {
|
|
18
|
-
// Using a simple QR code API service (you might want to replace this with a library)
|
|
19
|
-
return `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(text)}`;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const copyToClipboard = () => {
|
|
23
|
-
if (value) {
|
|
24
|
-
navigator.clipboard.writeText(value);
|
|
25
|
-
}
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
if (readonly) {
|
|
29
|
-
return (
|
|
30
|
-
<div className="flex items-center gap-3">
|
|
31
|
-
<span className="text-sm flex-1 truncate">{value || <EmptyValue />}</span>
|
|
32
|
-
{value && (
|
|
33
|
-
<Button
|
|
34
|
-
type="button"
|
|
35
|
-
variant="outline"
|
|
36
|
-
size="sm"
|
|
37
|
-
onClick={() => setShowQR(!showQR)}
|
|
38
|
-
>
|
|
39
|
-
<QrCode className="w-4 h-4 mr-2" />
|
|
40
|
-
{showQR ? 'Hide' : 'Show'} QR
|
|
41
|
-
</Button>
|
|
42
|
-
)}
|
|
43
|
-
</div>
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return (
|
|
48
|
-
<div className="space-y-3">
|
|
49
|
-
<div className="flex items-center gap-2">
|
|
50
|
-
<Input
|
|
51
|
-
// DOM pass-through onto the real focusable control (objectui#3318).
|
|
52
|
-
{...toDomProps(props)}
|
|
53
|
-
type="text"
|
|
54
|
-
value={value || ''}
|
|
55
|
-
onChange={(e) => onChange(e.target.value)}
|
|
56
|
-
placeholder={config?.placeholder || 'Enter text for QR code'}
|
|
57
|
-
disabled={readonly || props.disabled}
|
|
58
|
-
className={props.className}
|
|
59
|
-
// AFTER the spread so this widget's own computation wins: `error`
|
|
60
|
-
// is the published validation slot (#3222), `!!undefined` →
|
|
61
|
-
// explicit "false".
|
|
62
|
-
aria-invalid={!!error}
|
|
63
|
-
/>
|
|
64
|
-
{value && (
|
|
65
|
-
<>
|
|
66
|
-
<Button
|
|
67
|
-
type="button"
|
|
68
|
-
variant="outline"
|
|
69
|
-
size="sm"
|
|
70
|
-
onClick={copyToClipboard}
|
|
71
|
-
>
|
|
72
|
-
<Copy className="w-4 h-4" />
|
|
73
|
-
</Button>
|
|
74
|
-
<Button
|
|
75
|
-
type="button"
|
|
76
|
-
variant="outline"
|
|
77
|
-
size="sm"
|
|
78
|
-
onClick={() => setShowQR(!showQR)}
|
|
79
|
-
>
|
|
80
|
-
<QrCode className="w-4 h-4" />
|
|
81
|
-
</Button>
|
|
82
|
-
</>
|
|
83
|
-
)}
|
|
84
|
-
</div>
|
|
85
|
-
|
|
86
|
-
{showQR && value && (
|
|
87
|
-
<div className="border rounded p-4 bg-white flex justify-center">
|
|
88
|
-
<img
|
|
89
|
-
src={getQRCodeUrl(value)}
|
|
90
|
-
alt="QR Code"
|
|
91
|
-
className="w-48 h-48"
|
|
92
|
-
/>
|
|
93
|
-
</div>
|
|
94
|
-
)}
|
|
95
|
-
|
|
96
|
-
{value && (
|
|
97
|
-
<p className="text-xs text-muted-foreground">
|
|
98
|
-
QR code contains: {value.length} characters
|
|
99
|
-
</p>
|
|
100
|
-
)}
|
|
101
|
-
</div>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Cascading / role-gated `radio` options (#2715) — parity with the single
|
|
11
|
-
* `SelectField` (ADR-0058 / #2284).
|
|
12
|
-
*
|
|
13
|
-
* Exercises the observable widget behaviour: the dependency gate (a "select the
|
|
14
|
-
* parent first" empty-state) and the scalar cascade clear (a value dropped when
|
|
15
|
-
* the offered set no longer includes it). The per-option filtering itself is
|
|
16
|
-
* unit-tested in `@object-ui/core` (`optionRules.test.ts`); here we prove the
|
|
17
|
-
* radio group wires `dependentValues` + predicate scope into that resolver via
|
|
18
|
-
* the shared `useCascadingOptions` hook.
|
|
19
|
-
*/
|
|
20
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
21
|
-
import React from 'react';
|
|
22
|
-
import { render, screen } from '@testing-library/react';
|
|
23
|
-
import '@testing-library/jest-dom';
|
|
24
|
-
import { PredicateScopeProvider } from '@object-ui/react';
|
|
25
|
-
import { RadioField } from './RadioField';
|
|
26
|
-
|
|
27
|
-
const provinceField = {
|
|
28
|
-
name: 'province',
|
|
29
|
-
type: 'radio',
|
|
30
|
-
dependsOn: 'country',
|
|
31
|
-
options: [
|
|
32
|
-
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
|
|
33
|
-
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
|
|
34
|
-
],
|
|
35
|
-
} as any;
|
|
36
|
-
|
|
37
|
-
beforeEach(() => {
|
|
38
|
-
vi.clearAllMocks();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe('RadioField — dependency gating (#2715)', () => {
|
|
42
|
-
it('gates with a "select parent first" hint while the controlling field is empty', () => {
|
|
43
|
-
render(
|
|
44
|
-
<RadioField
|
|
45
|
-
value={undefined}
|
|
46
|
-
onChange={vi.fn()}
|
|
47
|
-
field={provinceField}
|
|
48
|
-
{...({ name: 'province', dependentValues: {} } as any)}
|
|
49
|
-
/>,
|
|
50
|
-
);
|
|
51
|
-
const gate = screen.getByTestId('radio-empty-province');
|
|
52
|
-
expect(gate).toHaveTextContent(/select country first/i);
|
|
53
|
-
// Gated → no radios offered.
|
|
54
|
-
expect(screen.queryAllByRole('radio')).toHaveLength(0);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('unlocks the radios once the controlling field is set', () => {
|
|
58
|
-
render(
|
|
59
|
-
<RadioField
|
|
60
|
-
value={undefined}
|
|
61
|
-
onChange={vi.fn()}
|
|
62
|
-
field={provinceField}
|
|
63
|
-
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
64
|
-
/>,
|
|
65
|
-
);
|
|
66
|
-
expect(screen.queryByTestId('radio-empty-province')).not.toBeInTheDocument();
|
|
67
|
-
// Only the `cn` option is offered.
|
|
68
|
-
expect(screen.getByRole('radio', { name: 'Zhejiang' })).toBeInTheDocument();
|
|
69
|
-
expect(screen.queryByRole('radio', { name: 'California' })).not.toBeInTheDocument();
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe('RadioField — cascade clear (#2715)', () => {
|
|
74
|
-
it('clears a value the parent change no longer offers', () => {
|
|
75
|
-
const onChange = vi.fn();
|
|
76
|
-
render(
|
|
77
|
-
<RadioField
|
|
78
|
-
value={'ca'}
|
|
79
|
-
onChange={onChange}
|
|
80
|
-
field={provinceField}
|
|
81
|
-
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
82
|
-
/>,
|
|
83
|
-
);
|
|
84
|
-
// 'ca' is a US province — under country=cn it is not offered, so it is dropped.
|
|
85
|
-
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('keeps a value that is still offered', () => {
|
|
89
|
-
const onChange = vi.fn();
|
|
90
|
-
render(
|
|
91
|
-
<RadioField
|
|
92
|
-
value={'zj'}
|
|
93
|
-
onChange={onChange}
|
|
94
|
-
field={provinceField}
|
|
95
|
-
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
96
|
-
/>,
|
|
97
|
-
);
|
|
98
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe('RadioField — role / context gating (#2715)', () => {
|
|
103
|
-
const tierField = {
|
|
104
|
-
name: 'tier',
|
|
105
|
-
type: 'radio',
|
|
106
|
-
options: [
|
|
107
|
-
{ label: 'Standard', value: 'standard' },
|
|
108
|
-
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
|
|
109
|
-
],
|
|
110
|
-
} as any;
|
|
111
|
-
|
|
112
|
-
it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
|
|
113
|
-
const onChange = vi.fn();
|
|
114
|
-
render(
|
|
115
|
-
<PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
|
|
116
|
-
<RadioField
|
|
117
|
-
value={'admin_only'}
|
|
118
|
-
onChange={onChange}
|
|
119
|
-
field={tierField}
|
|
120
|
-
{...({ name: 'tier', dependentValues: {} } as any)}
|
|
121
|
-
/>
|
|
122
|
-
</PredicateScopeProvider>,
|
|
123
|
-
);
|
|
124
|
-
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('keeps an admin-only value for an admin', () => {
|
|
128
|
-
const onChange = vi.fn();
|
|
129
|
-
render(
|
|
130
|
-
<PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
|
|
131
|
-
<RadioField
|
|
132
|
-
value={'admin_only'}
|
|
133
|
-
onChange={onChange}
|
|
134
|
-
field={tierField}
|
|
135
|
-
{...({ name: 'tier', dependentValues: {} } as any)}
|
|
136
|
-
/>
|
|
137
|
-
</PredicateScopeProvider>,
|
|
138
|
-
);
|
|
139
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
140
|
-
});
|
|
141
|
-
});
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import React, { useId, useEffect } from 'react';
|
|
2
|
-
import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { isValueStillOffered, type OptionLike } from '@object-ui/core';
|
|
4
|
-
import { FieldWidgetComponentProps } from './types';
|
|
5
|
-
import { toDomProps } from './toDomProps';
|
|
6
|
-
import { toHostGroupProps } from './toHostGroupProps';
|
|
7
|
-
import { OptionsEmptyState } from './OptionsEmptyState';
|
|
8
|
-
import { useCascadingOptions } from './useCascadingOptions';
|
|
9
|
-
|
|
10
|
-
type Option = OptionLike;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* RadioField - choose exactly one value from a fixed option set, rendered as a
|
|
14
|
-
* radio group. The stored value is the selected option value (string). Used for
|
|
15
|
-
* the `radio` field type.
|
|
16
|
-
*
|
|
17
|
-
* Like the single `SelectField`, options support per-option `visibleWhen`
|
|
18
|
-
* cascading + `dependsOn` gating (ADR-0058 / #2715), resolved through the shared
|
|
19
|
-
* {@link useCascadingOptions} hook: the offered radios narrow against the live
|
|
20
|
-
* form record + `current_user`, the control is gated behind a "select the parent
|
|
21
|
-
* first" hint while a dependency is empty, and a value no longer offered (parent
|
|
22
|
-
* changed / predicate flipped) is cleared.
|
|
23
|
-
*/
|
|
24
|
-
export function RadioField({
|
|
25
|
-
value,
|
|
26
|
-
onChange,
|
|
27
|
-
field,
|
|
28
|
-
readonly,
|
|
29
|
-
className,
|
|
30
|
-
dependentValues,
|
|
31
|
-
dependsOn: dependsOnProp,
|
|
32
|
-
emptyHint,
|
|
33
|
-
dataSource: _dataSource,
|
|
34
|
-
error,
|
|
35
|
-
...props
|
|
36
|
-
}: FieldWidgetComponentProps<string>) {
|
|
37
|
-
const config = field as any;
|
|
38
|
-
const rawOptions: Option[] = config?.options || [];
|
|
39
|
-
const groupId = useId();
|
|
40
|
-
const fieldName = props.name || config?.name || props.id || '';
|
|
41
|
-
|
|
42
|
-
const dependsOn = config?.dependsOn ?? dependsOnProp;
|
|
43
|
-
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
|
|
44
|
-
rawOptions,
|
|
45
|
-
dependsOn,
|
|
46
|
-
dependentValues,
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
// Cascade clear: once the offered set no longer includes the current value
|
|
50
|
-
// (parent changed / predicate flipped), drop it so no stale pair persists.
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
if (readonly) return;
|
|
53
|
-
// Never configured → nothing to prune against; see `MultiSelectField`'s
|
|
54
|
-
// copy of this guard for the measured failure (objectui#4220).
|
|
55
|
-
if (rawOptions.length === 0) return;
|
|
56
|
-
// Gated → the authored list is withheld until the `dependsOn` parent is
|
|
57
|
-
// chosen, so the empty offered set is missing information, not a verdict on
|
|
58
|
-
// the stored value; clearing here fired on mount (objectui#4247). Same
|
|
59
|
-
// reasoning, at length, in `MultiSelectField`.
|
|
60
|
-
if (gated) return;
|
|
61
|
-
if (value === undefined || value === null || (value as unknown) === '') return;
|
|
62
|
-
if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
|
|
63
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
64
|
-
}, [options, gated]);
|
|
65
|
-
|
|
66
|
-
// The host's group label has to be consumed on EVERY surface this widget can
|
|
67
|
-
// render, not only the `RadioGroup` (objectui#3990): both branches below return
|
|
68
|
-
// before that spread, which is how a field-level `readonly: true` and a
|
|
69
|
-
// zero-option list ended up with a published label id and no consumer at all.
|
|
70
|
-
// See `toHostGroupProps` — note the role it emits is `group`, not the
|
|
71
|
-
// `radiogroup` the editable branch keeps: there is not one radio left in
|
|
72
|
-
// either of these surfaces.
|
|
73
|
-
const hostGroupProps = toHostGroupProps(props);
|
|
74
|
-
|
|
75
|
-
if (readonly) {
|
|
76
|
-
// `EmptyValue`'s own `aria-label` ("No value") is outranked by
|
|
77
|
-
// `aria-labelledby` per accname, and on the `generic` role that placeholder
|
|
78
|
-
// span carries it was never exposed as a name anyway.
|
|
79
|
-
if (value == null || value === '') return <EmptyValue {...hostGroupProps} />;
|
|
80
|
-
// Label from the raw set so a stored value hidden by `visibleWhen` still
|
|
81
|
-
// renders its label rather than a bare id.
|
|
82
|
-
const opt = rawOptions.find((o) => o.value === value);
|
|
83
|
-
return <span {...hostGroupProps} className="text-sm">{opt?.label || String(value)}</span>;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// No offered options is unfillable — surface a legible state instead of an
|
|
87
|
-
// empty radio group: the host's `emptyHint` when it computed one, else this
|
|
88
|
-
// widget's own translated copy. Shared with the select / multiselect /
|
|
89
|
-
// checkboxes so the four cannot drift again (objectui#3231).
|
|
90
|
-
if (options.length === 0) {
|
|
91
|
-
return (
|
|
92
|
-
<OptionsEmptyState
|
|
93
|
-
emptyHint={emptyHint}
|
|
94
|
-
gated={gated}
|
|
95
|
-
dependsOnFields={dependsOnFields}
|
|
96
|
-
testId={fieldName ? `radio-empty-${fieldName}` : undefined}
|
|
97
|
-
className="min-h-9"
|
|
98
|
-
// This box IS the field in this state, so it is what the host label
|
|
99
|
-
// names (objectui#3990).
|
|
100
|
-
hostGroupProps={hostGroupProps}
|
|
101
|
-
/>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return (
|
|
106
|
-
// DOM pass-through onto the radiogroup (objectui#3318). It also carries the
|
|
107
|
-
// host's group label: when the form renderer associates its `<FormLabel>` by
|
|
108
|
-
// IDREF (`aria-labelledby`, objectui#3961) that key rides in this same
|
|
109
|
-
// whitelist spread — `toDomProps` forwards the whole `aria-` family — so this
|
|
110
|
-
// widget needs no branch of its own. `radiogroup` is already a member of the
|
|
111
|
-
// group role family, so nothing here overrides it with `role="group"`: it is
|
|
112
|
-
// the more specific role AND the correct carrier of `aria-invalid` below.
|
|
113
|
-
//
|
|
114
|
-
// Unlike Radix
|
|
115
|
-
// `Select.Root` (#3306) this Root IS a real DOM element — a
|
|
116
|
-
// `<div role="radiogroup">` — and `radiogroup` is exactly the role
|
|
117
|
-
// WAI-ARIA designates to carry `aria-invalid` for a set of radios
|
|
118
|
-
// (`radio` itself does not support it): the group's state is announced
|
|
119
|
-
// when focus lands on any radio inside it.
|
|
120
|
-
<RadioGroup
|
|
121
|
-
{...toDomProps(props)}
|
|
122
|
-
value={value ?? ''}
|
|
123
|
-
onValueChange={onChange}
|
|
124
|
-
disabled={props.disabled}
|
|
125
|
-
className={className}
|
|
126
|
-
// AFTER the spread so this widget's own computation wins (#3222).
|
|
127
|
-
aria-invalid={!!error}
|
|
128
|
-
>
|
|
129
|
-
{options.map((opt) => {
|
|
130
|
-
// Radix speaks strings — stringify the (possibly numeric,
|
|
131
|
-
// #3090-widened) authored value at this boundary.
|
|
132
|
-
const value = String(opt.value);
|
|
133
|
-
const id = `${groupId}-${value}`;
|
|
134
|
-
return (
|
|
135
|
-
<div key={value} className="flex items-center space-x-2">
|
|
136
|
-
<RadioGroupItem value={value} id={id} data-testid={`radio-option-${value}`} />
|
|
137
|
-
<Label htmlFor={id} className="font-normal">{opt.label}</Label>
|
|
138
|
-
</div>
|
|
139
|
-
);
|
|
140
|
-
})}
|
|
141
|
-
</RadioGroup>
|
|
142
|
-
);
|
|
143
|
-
}
|