@object-ui/fields 17.5.0 → 17.7.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 +6150 -0
- package/README.md +108 -8
- package/dist/FieldEditWidget.d.ts +70 -8
- package/dist/coerceToSafeValue.d.ts +54 -0
- package/dist/currency.d.ts +1 -1
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.css +53 -11
- package/dist/index.d.ts +653 -128
- package/dist/index.js +4275 -3171
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +20 -4
- package/dist/widgets/BooleanField.d.ts +2 -2
- 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 +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +43 -2
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +10 -4
- package/dist/widgets/GridField.d.ts +102 -86
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +31 -4
- package/dist/widgets/LookupField.d.ts +9 -1
- package/dist/widgets/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +10 -3
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +38 -8
- package/dist/widgets/PasswordField.d.ts +15 -1
- package/dist/widgets/PeoplePicker.d.ts +17 -2
- package/dist/widgets/PercentField.d.ts +3 -2
- package/dist/widgets/PersonRow.d.ts +13 -2
- 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 +44 -1
- package/dist/widgets/RecordPickerDialog.d.ts +33 -16
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- package/dist/widgets/SelectionTray.d.ts +13 -2
- 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 +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- 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/file-affordance.d.ts +33 -0
- package/dist/widgets/file-size-guard.d.ts +5 -23
- package/dist/widgets/file-value.d.ts +38 -9
- package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
- package/dist/widgets/nativeDateValue.d.ts +9 -27
- package/dist/widgets/number-format.d.ts +137 -0
- package/dist/widgets/numberBadInput.d.ts +135 -0
- package/dist/widgets/personDisplay.d.ts +22 -4
- package/dist/widgets/richTextDisplay.d.ts +116 -0
- package/dist/widgets/toDomProps.d.ts +10 -1
- package/dist/widgets/toHostGroupProps.d.ts +172 -21
- package/dist/widgets/toHostProps.d.ts +105 -0
- package/dist/widgets/types.d.ts +19 -6
- package/dist/widgets/upload-error-message.d.ts +6 -0
- package/dist/widgets/uploadingScope.d.ts +142 -0
- package/dist/widgets/useCascadingOptions.d.ts +3 -2
- package/dist/widgets/useRecordQuery.d.ts +4 -2
- package/dist/widgets/useUploadingSignal.d.ts +8 -0
- package/package.json +11 -13
- 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,176 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { cn, Textarea, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { useObjectTranslation } from '@object-ui/react';
|
|
4
|
-
import { FullscreenFieldEditor } from './FullscreenFieldEditor';
|
|
5
|
-
import { FieldWidgetComponentProps } from './types';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The rich-text editing surface, rendered by `RichTextField` in BOTH positions:
|
|
9
|
-
* inline in the form, and inside the fullscreen dialog.
|
|
10
|
-
*
|
|
11
|
-
* Sharing it is the point of objectui#3301's acceptance criterion "the dialog
|
|
12
|
-
* holds the real editor". Had the dialog inlined a bare `<Textarea>` instead,
|
|
13
|
-
* the two surfaces would already disagree (no format indicator) and would keep
|
|
14
|
-
* disagreeing every time this widget gains an affordance — whatever the editor
|
|
15
|
-
* grows into (a toolbar, a TipTap/Lexical instance, a preview toggle), both
|
|
16
|
-
* positions get it at once because there is only one of them.
|
|
17
|
-
*
|
|
18
|
-
* `fullHeight` is the only difference between the two renderings: inline the
|
|
19
|
-
* textarea is `rows`-sized, in the dialog it fills the available height.
|
|
20
|
-
*/
|
|
21
|
-
function RichTextEditorSurface({
|
|
22
|
-
value,
|
|
23
|
-
onChange,
|
|
24
|
-
formatLabel,
|
|
25
|
-
hint,
|
|
26
|
-
placeholder,
|
|
27
|
-
rows,
|
|
28
|
-
disabled,
|
|
29
|
-
error,
|
|
30
|
-
className,
|
|
31
|
-
fullHeight,
|
|
32
|
-
autoFocus,
|
|
33
|
-
textareaTestId,
|
|
34
|
-
overlay,
|
|
35
|
-
}: {
|
|
36
|
-
value: string;
|
|
37
|
-
onChange: (next: string) => void;
|
|
38
|
-
formatLabel: string;
|
|
39
|
-
hint: string;
|
|
40
|
-
placeholder?: string;
|
|
41
|
-
rows?: number;
|
|
42
|
-
disabled?: boolean;
|
|
43
|
-
error?: string;
|
|
44
|
-
className?: string;
|
|
45
|
-
fullHeight?: boolean;
|
|
46
|
-
autoFocus?: boolean;
|
|
47
|
-
textareaTestId?: string;
|
|
48
|
-
/** Absolutely-positioned children over the textarea (the expand affordance). */
|
|
49
|
-
overlay?: React.ReactNode;
|
|
50
|
-
}) {
|
|
51
|
-
return (
|
|
52
|
-
<div className={cn('space-y-2', fullHeight && 'flex flex-col h-full')}>
|
|
53
|
-
<div className="flex items-center justify-between text-xs text-gray-500">
|
|
54
|
-
<span>{formatLabel}</span>
|
|
55
|
-
<span className="italic">{hint}</span>
|
|
56
|
-
</div>
|
|
57
|
-
<div className={cn('relative', fullHeight && 'flex-1 min-h-0')}>
|
|
58
|
-
<Textarea
|
|
59
|
-
value={value || ''}
|
|
60
|
-
onChange={(e) => onChange(e.target.value)}
|
|
61
|
-
placeholder={placeholder}
|
|
62
|
-
disabled={disabled}
|
|
63
|
-
autoFocus={autoFocus}
|
|
64
|
-
rows={fullHeight ? undefined : rows}
|
|
65
|
-
// `text-base` in the dialog for the same reason `TextAreaField` uses
|
|
66
|
-
// it there: sub-16px inputs make iOS Safari zoom on focus, which is
|
|
67
|
-
// exactly wrong for a surface the user opened to get more room.
|
|
68
|
-
className={cn(
|
|
69
|
-
'font-mono',
|
|
70
|
-
fullHeight ? 'text-base h-full min-h-full resize-none' : 'text-sm',
|
|
71
|
-
className,
|
|
72
|
-
)}
|
|
73
|
-
aria-invalid={!!error}
|
|
74
|
-
data-testid={textareaTestId}
|
|
75
|
-
/>
|
|
76
|
-
{overlay}
|
|
77
|
-
</div>
|
|
78
|
-
</div>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Rich text field with markdown/HTML support
|
|
84
|
-
* For now, this is a simple textarea. A full implementation would use
|
|
85
|
-
* a rich text editor like TipTap, Lexical, or Slate.
|
|
86
|
-
*
|
|
87
|
-
* Reached by forms as `field:markdown` and `field:html`; both resolve here.
|
|
88
|
-
*
|
|
89
|
-
* ## Fullscreen editing (objectui#3301)
|
|
90
|
-
*
|
|
91
|
-
* `ObjectForm` stamps `mobile_fullscreen` onto the metadata of every long-text
|
|
92
|
-
* field — textarea AND rich-text — when `ObjectFormSchema.mobile
|
|
93
|
-
* .fullscreenLongText` is set, and `ObjectFormSchema.mobile`'s own JSDoc has
|
|
94
|
-
* always promised "textarea/rich-text get an expand button". This widget never
|
|
95
|
-
* read the flag, so for `field:markdown` / `field:html` that promise did
|
|
96
|
-
* nothing at all: the producer stamped, and no consumer existed.
|
|
97
|
-
*
|
|
98
|
-
* The flag is read off `field` and nowhere else — the single metadata carrier
|
|
99
|
-
* since objectui#3233 — matching `TextAreaField` read for read, so a
|
|
100
|
-
* misspelled flag stays inert in both widgets rather than being caught by a
|
|
101
|
-
* tolerant fallback in one of them. The affordance and dialog themselves come
|
|
102
|
-
* from the shared `FullscreenFieldEditor`, so one form-level setting keeps
|
|
103
|
-
* producing one behaviour across both widgets.
|
|
104
|
-
*/
|
|
105
|
-
export function RichTextField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
106
|
-
const { t } = useObjectTranslation();
|
|
107
|
-
if (readonly) {
|
|
108
|
-
return (
|
|
109
|
-
<div
|
|
110
|
-
className="text-sm prose prose-sm max-w-none"
|
|
111
|
-
>
|
|
112
|
-
{value || <EmptyValue />}
|
|
113
|
-
</div>
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const richField = field as any;
|
|
118
|
-
const rows = richField?.rows || 8;
|
|
119
|
-
const format = richField?.format || 'markdown'; // 'markdown' or 'html'
|
|
120
|
-
// Same single read as `TextAreaField`: the field metadata is the only
|
|
121
|
-
// carrier, and this widget is the second consumer the flag always had.
|
|
122
|
-
const showFullscreenButton = Boolean(richField?.mobile_fullscreen);
|
|
123
|
-
// Resolved once and given to BOTH renderings of the editor (objectui#3402) —
|
|
124
|
-
// exactly like `formatLabel` / `hint` / `placeholder` below, and for the same
|
|
125
|
-
// reason. Landing it on the inline surface alone left a disabled rich-text
|
|
126
|
-
// field greyed out next to a live expand button whose dialog committed any
|
|
127
|
-
// edit through `onCommit`. `disabled` also carries the form's `isSubmitting`.
|
|
128
|
-
const disabled = Boolean(props.disabled);
|
|
129
|
-
|
|
130
|
-
// Resolved once and handed to BOTH renderings of the editor, so the dialog
|
|
131
|
-
// cannot drift into showing different copy than the inline surface.
|
|
132
|
-
const formatLabel = t('fields.richText.format', { format, defaultValue: `Format: ${format}` });
|
|
133
|
-
const hint = t('fields.richText.basicEditorHint', { defaultValue: 'Rich text editor (basic)' });
|
|
134
|
-
const placeholder =
|
|
135
|
-
richField?.placeholder ||
|
|
136
|
-
t('fields.richText.placeholder', { defaultValue: 'Enter text…' });
|
|
137
|
-
|
|
138
|
-
return (
|
|
139
|
-
<RichTextEditorSurface
|
|
140
|
-
value={value}
|
|
141
|
-
onChange={onChange}
|
|
142
|
-
formatLabel={formatLabel}
|
|
143
|
-
hint={hint}
|
|
144
|
-
placeholder={placeholder}
|
|
145
|
-
rows={rows}
|
|
146
|
-
disabled={readonly || disabled}
|
|
147
|
-
error={error}
|
|
148
|
-
className={props.className}
|
|
149
|
-
overlay={
|
|
150
|
-
showFullscreenButton && (
|
|
151
|
-
<FullscreenFieldEditor
|
|
152
|
-
value={value ?? ''}
|
|
153
|
-
onCommit={onChange}
|
|
154
|
-
label={richField?.label}
|
|
155
|
-
testIdPrefix="richtext"
|
|
156
|
-
disabled={disabled}
|
|
157
|
-
>
|
|
158
|
-
{(draft, setDraft, editorDisabled) => (
|
|
159
|
-
<RichTextEditorSurface
|
|
160
|
-
value={draft}
|
|
161
|
-
onChange={setDraft}
|
|
162
|
-
formatLabel={formatLabel}
|
|
163
|
-
hint={hint}
|
|
164
|
-
placeholder={placeholder}
|
|
165
|
-
disabled={editorDisabled}
|
|
166
|
-
autoFocus
|
|
167
|
-
fullHeight
|
|
168
|
-
textareaTestId="richtext-fullscreen-input"
|
|
169
|
-
/>
|
|
170
|
-
)}
|
|
171
|
-
</FullscreenFieldEditor>
|
|
172
|
-
)
|
|
173
|
-
}
|
|
174
|
-
/>
|
|
175
|
-
);
|
|
176
|
-
}
|
|
@@ -1,138 +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 `select` options (#2284).
|
|
11
|
-
*
|
|
12
|
-
* Exercises the observable, non-Radix behaviour of {@link SelectField}: the
|
|
13
|
-
* dependency gate (a "select the parent first" empty-state) and the cascade
|
|
14
|
-
* clear (a value dropped when the offered set no longer includes it). The
|
|
15
|
-
* per-option filtering itself is unit-tested in `@object-ui/core`
|
|
16
|
-
* (`optionRules.test.ts`); here we prove the widget wires `dependentValues` +
|
|
17
|
-
* predicate scope into that resolver.
|
|
18
|
-
*/
|
|
19
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
20
|
-
import React from 'react';
|
|
21
|
-
import { render, screen } from '@testing-library/react';
|
|
22
|
-
import '@testing-library/jest-dom';
|
|
23
|
-
import { PredicateScopeProvider } from '@object-ui/react';
|
|
24
|
-
import { SelectField } from './SelectField';
|
|
25
|
-
|
|
26
|
-
const provinceField = {
|
|
27
|
-
name: 'province',
|
|
28
|
-
type: 'select',
|
|
29
|
-
dependsOn: 'country',
|
|
30
|
-
options: [
|
|
31
|
-
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
|
|
32
|
-
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
|
|
33
|
-
],
|
|
34
|
-
} as any;
|
|
35
|
-
|
|
36
|
-
beforeEach(() => {
|
|
37
|
-
vi.clearAllMocks();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe('SelectField — dependency gating (#2284)', () => {
|
|
41
|
-
it('gates with a "select parent first" hint while the controlling field is empty', () => {
|
|
42
|
-
render(
|
|
43
|
-
<SelectField
|
|
44
|
-
value={undefined}
|
|
45
|
-
onChange={vi.fn()}
|
|
46
|
-
field={provinceField}
|
|
47
|
-
{...({ name: 'province', dependentValues: {} } as any)}
|
|
48
|
-
/>,
|
|
49
|
-
);
|
|
50
|
-
const gate = screen.getByTestId('select-empty-province');
|
|
51
|
-
expect(gate).toHaveTextContent(/select country first/i);
|
|
52
|
-
// Gated → no live combobox is offered.
|
|
53
|
-
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('unlocks the combobox once the controlling field is set', () => {
|
|
57
|
-
render(
|
|
58
|
-
<SelectField
|
|
59
|
-
value={undefined}
|
|
60
|
-
onChange={vi.fn()}
|
|
61
|
-
field={provinceField}
|
|
62
|
-
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
63
|
-
/>,
|
|
64
|
-
);
|
|
65
|
-
expect(screen.queryByTestId('select-empty-province')).not.toBeInTheDocument();
|
|
66
|
-
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe('SelectField — cascade clear (#2284)', () => {
|
|
71
|
-
it('clears a value the parent change no longer offers', () => {
|
|
72
|
-
const onChange = vi.fn();
|
|
73
|
-
render(
|
|
74
|
-
<SelectField
|
|
75
|
-
value={'ca'}
|
|
76
|
-
onChange={onChange}
|
|
77
|
-
field={provinceField}
|
|
78
|
-
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
79
|
-
/>,
|
|
80
|
-
);
|
|
81
|
-
// 'ca' is a US province — under country=cn it is not offered, so it is dropped.
|
|
82
|
-
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('keeps a value that is still offered', () => {
|
|
86
|
-
const onChange = vi.fn();
|
|
87
|
-
render(
|
|
88
|
-
<SelectField
|
|
89
|
-
value={'zj'}
|
|
90
|
-
onChange={onChange}
|
|
91
|
-
field={provinceField}
|
|
92
|
-
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
93
|
-
/>,
|
|
94
|
-
);
|
|
95
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
describe('SelectField — role / context gating (#2284)', () => {
|
|
100
|
-
const tierField = {
|
|
101
|
-
name: 'tier',
|
|
102
|
-
type: 'select',
|
|
103
|
-
options: [
|
|
104
|
-
{ label: 'Standard', value: 'standard' },
|
|
105
|
-
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
|
|
106
|
-
],
|
|
107
|
-
} as any;
|
|
108
|
-
|
|
109
|
-
it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
|
|
110
|
-
const onChange = vi.fn();
|
|
111
|
-
render(
|
|
112
|
-
<PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
|
|
113
|
-
<SelectField
|
|
114
|
-
value={'admin_only'}
|
|
115
|
-
onChange={onChange}
|
|
116
|
-
field={tierField}
|
|
117
|
-
{...({ name: 'tier', dependentValues: {} } as any)}
|
|
118
|
-
/>
|
|
119
|
-
</PredicateScopeProvider>,
|
|
120
|
-
);
|
|
121
|
-
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('keeps an admin-only value for an admin', () => {
|
|
125
|
-
const onChange = vi.fn();
|
|
126
|
-
render(
|
|
127
|
-
<PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
|
|
128
|
-
<SelectField
|
|
129
|
-
value={'admin_only'}
|
|
130
|
-
onChange={onChange}
|
|
131
|
-
field={tierField}
|
|
132
|
-
{...({ name: 'tier', dependentValues: {} } as any)}
|
|
133
|
-
/>
|
|
134
|
-
</PredicateScopeProvider>,
|
|
135
|
-
);
|
|
136
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
137
|
-
});
|
|
138
|
-
});
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import React, { useEffect } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
Select,
|
|
4
|
-
SelectContent,
|
|
5
|
-
SelectItem,
|
|
6
|
-
SelectTrigger,
|
|
7
|
-
SelectValue,
|
|
8
|
-
EmptyValue,
|
|
9
|
-
} from '@object-ui/components';
|
|
10
|
-
import { isValueStillOffered } from '@object-ui/core';
|
|
11
|
-
import { SelectFieldMetadata } from '@object-ui/types';
|
|
12
|
-
import { useFieldTranslation } from './useFieldTranslation';
|
|
13
|
-
import { FieldWidgetComponentProps } from './types';
|
|
14
|
-
import { toDomProps } from './toDomProps';
|
|
15
|
-
import { MultiSelectField } from './MultiSelectField';
|
|
16
|
-
import { OptionsEmptyState } from './OptionsEmptyState';
|
|
17
|
-
import { useCascadingOptions } from './useCascadingOptions';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* SelectField - dropdown selection widget.
|
|
21
|
-
*
|
|
22
|
-
* A field declared `multiple: true` selects zero-or-more values (spec:
|
|
23
|
-
* `multiple` is valid on `select`), so it renders the multi-value chip picker
|
|
24
|
-
* — the same widget the `multiselect` type uses. Single-value selects keep the
|
|
25
|
-
* cascading dropdown below.
|
|
26
|
-
*
|
|
27
|
-
* The FORM no longer arrives here with `multiple` (objectui#3986):
|
|
28
|
-
* `mapFieldTypeToFormType` now resolves a `select` + `multiple: true` field to
|
|
29
|
-
* `field:multiselect`, so the object-form path renders `MultiSelectField`
|
|
30
|
-
* directly under its own registry id — which is what carries the
|
|
31
|
-
* `labelling: 'group'` declaration the host label needs. Deciding the widget at
|
|
32
|
-
* the type-resolution layer is what keeps the declaration and the render from
|
|
33
|
-
* disagreeing; a delegation invisible to the resolver could not.
|
|
34
|
-
*
|
|
35
|
-
* The branch below stays because it is NOT dead — measured entrances that reach
|
|
36
|
-
* it with `multiple` set, none of which consult that resolver:
|
|
37
|
-
*
|
|
38
|
-
* - **the inline grid editor** — `FieldEditWidget` looks `select` up in its own
|
|
39
|
-
* `EDIT_WIDGETS` table, which SHORT-CIRCUITS before the alias map is
|
|
40
|
-
* consulted, and forwards the whole metadata object as `field`;
|
|
41
|
-
* - **`ActionParamDialog`** — `paramToField` resolves through
|
|
42
|
-
* `resolveFormWidgetType`, which likewise returns `select` from
|
|
43
|
-
* `fieldWidgetMap` before reaching the alias map, and carries
|
|
44
|
-
* `multiple: param.multiple` on the field it builds;
|
|
45
|
-
* - **hand-written SDUI** — a `{ type: 'field:select' }` node whose metadata
|
|
46
|
-
* declares `multiple`, which addresses this widget by name.
|
|
47
|
-
*
|
|
48
|
-
* All three then inherit multi-select from here identically, with no drift.
|
|
49
|
-
*
|
|
50
|
-
* Both branches resolve per-option `visibleWhen` cascading / role-gating through
|
|
51
|
-
* the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
|
|
52
|
-
* in lockstep.
|
|
53
|
-
*/
|
|
54
|
-
export function SelectField(props: FieldWidgetComponentProps<any>) {
|
|
55
|
-
const config = props.field as SelectFieldMetadata | undefined;
|
|
56
|
-
if ((config as any)?.multiple) {
|
|
57
|
-
// NOT `toDomProps` — this is a widget-to-widget delegation, not a DOM
|
|
58
|
-
// spread. `MultiSelectField` implements the same contract and needs the
|
|
59
|
-
// whole of it (`value`, `onChange`, `field`, `dataSource`, …); narrowing
|
|
60
|
-
// here to the DOM whitelist would hand it an empty widget.
|
|
61
|
-
return <MultiSelectField {...props} />;
|
|
62
|
-
}
|
|
63
|
-
return <SingleSelectField {...(props as FieldWidgetComponentProps<string>)} />;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* SingleSelectField - single-value dropdown with configurable options.
|
|
68
|
-
*
|
|
69
|
-
* Supports cascading / role-gated options (#2284): each option may carry a
|
|
70
|
-
* `visibleWhen` CEL predicate, evaluated against the live form record +
|
|
71
|
-
* `current_user`, so the offered set narrows as a controlling field changes
|
|
72
|
-
* (`record.country == 'cn'`) or by role (`'admin' in current_user.positions`). A
|
|
73
|
-
* field declares which sibling fields drive its list via `dependsOn`; while any
|
|
74
|
-
* of those is empty the control is gated with a "select the parent first" hint,
|
|
75
|
-
* mirroring the dependent-lookup UX.
|
|
76
|
-
*/
|
|
77
|
-
function SingleSelectField({
|
|
78
|
-
value,
|
|
79
|
-
onChange,
|
|
80
|
-
field,
|
|
81
|
-
readonly,
|
|
82
|
-
error,
|
|
83
|
-
dependentValues,
|
|
84
|
-
dependsOn: dependsOnProp,
|
|
85
|
-
emptyHint,
|
|
86
|
-
dataSource: _dataSource,
|
|
87
|
-
...props
|
|
88
|
-
}: FieldWidgetComponentProps<string>) {
|
|
89
|
-
const config = field as SelectFieldMetadata;
|
|
90
|
-
const rawOptions = config?.options || [];
|
|
91
|
-
const { t } = useFieldTranslation();
|
|
92
|
-
// Stable hook for automation/e2e — react-hook-form + Radix Select cannot be
|
|
93
|
-
// driven by synthetic DOM events, so e2e must target the trigger/options by a
|
|
94
|
-
// deterministic testid keyed on the field name. `props.name` is the
|
|
95
|
-
// react-hook-form field name spread in by the form renderer (FormField).
|
|
96
|
-
const fieldName = props.name || (config as any)?.name || props.id || '';
|
|
97
|
-
|
|
98
|
-
const dependsOn = (config as any)?.dependsOn ?? dependsOnProp;
|
|
99
|
-
const { options, gated, dependsOnFields } = useCascadingOptions(
|
|
100
|
-
rawOptions,
|
|
101
|
-
dependsOn,
|
|
102
|
-
dependentValues,
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
// Cascade clear: once the offered set no longer includes the current value
|
|
106
|
-
// (parent changed / predicate flipped), drop it so no stale pair persists.
|
|
107
|
-
useEffect(() => {
|
|
108
|
-
if (readonly) return;
|
|
109
|
-
// Never configured → nothing to prune against; see `MultiSelectField`'s
|
|
110
|
-
// copy of this guard for the measured failure (objectui#4220). Kept
|
|
111
|
-
// identical across the four option widgets: an authored list is what the
|
|
112
|
-
// cascade prunes, and all four render the same `OptionsEmptyState` when
|
|
113
|
-
// there is none.
|
|
114
|
-
if (rawOptions.length === 0) return;
|
|
115
|
-
// Gated → the authored list is withheld until the `dependsOn` parent is
|
|
116
|
-
// chosen, so the empty offered set is missing information, not a verdict on
|
|
117
|
-
// the stored value; clearing here fired on mount (objectui#4247). Same
|
|
118
|
-
// reasoning, at length, in `MultiSelectField`.
|
|
119
|
-
if (gated) return;
|
|
120
|
-
if (value === undefined || value === null || (value as unknown) === '') return;
|
|
121
|
-
if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
|
|
122
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
123
|
-
}, [options, gated]);
|
|
124
|
-
|
|
125
|
-
if (readonly) {
|
|
126
|
-
const option = rawOptions.find((o) => o.value === value);
|
|
127
|
-
const display = option?.label || value;
|
|
128
|
-
return display ? <span className="text-sm">{display}</span> : <EmptyValue />;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// A select with no options is unfillable — a silently-empty Radix dropdown
|
|
132
|
-
// reads as "broken widget" and hides the real cause. Surface a legible state:
|
|
133
|
-
// the host's `emptyHint` when it computed one (it knows the controlling
|
|
134
|
-
// fields' LABELS), else this widget's own translated copy. Shared with the
|
|
135
|
-
// other option widgets so the four cannot drift again (objectui#3231).
|
|
136
|
-
if (options.length === 0) {
|
|
137
|
-
return (
|
|
138
|
-
<OptionsEmptyState
|
|
139
|
-
emptyHint={emptyHint}
|
|
140
|
-
gated={gated}
|
|
141
|
-
dependsOnFields={dependsOnFields}
|
|
142
|
-
testId={fieldName ? `select-empty-${fieldName}` : undefined}
|
|
143
|
-
className="h-9"
|
|
144
|
-
/>
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Radix `Select.Root` renders no DOM element of its own — anything it does
|
|
149
|
-
// not recognise (every `aria-*` / `data-*` a host hands this widget) is
|
|
150
|
-
// silently DROPPED, never reaching a real element. That is how a required
|
|
151
|
-
// select failed validation with the red message on screen while assistive
|
|
152
|
-
// tech was told nothing (objectui#3306): the form renderer's `aria-invalid`
|
|
153
|
-
// / `aria-describedby` / `aria-required` landed on Root and vanished. The
|
|
154
|
-
// DOM pass-through therefore goes to `SelectTrigger` — the focusable
|
|
155
|
-
// `<button role="combobox">` a user and their screen reader actually
|
|
156
|
-
// interact with — with two deliberate exceptions kept on Root:
|
|
157
|
-
//
|
|
158
|
-
// - `name`: the ONE whitelist key Root genuinely consumes — it forwards it
|
|
159
|
-
// to the hidden native `<select>` that takes part in form submission.
|
|
160
|
-
// On the trigger it would sit uselessly on a non-submitter `<button>`.
|
|
161
|
-
// - `disabled`: Root is the single authority (it disables trigger, item
|
|
162
|
-
// interaction and the hidden select together); forwarding the raw prop to
|
|
163
|
-
// the trigger as well would give the state a second, OR-merged author.
|
|
164
|
-
const { name: domName, disabled: _domDisabled, ...triggerDomProps } = toDomProps(props);
|
|
165
|
-
|
|
166
|
-
return (
|
|
167
|
-
<Select
|
|
168
|
-
name={domName}
|
|
169
|
-
value={value}
|
|
170
|
-
onValueChange={onChange}
|
|
171
|
-
disabled={readonly || props.disabled}
|
|
172
|
-
>
|
|
173
|
-
<SelectTrigger
|
|
174
|
-
{...triggerDomProps}
|
|
175
|
-
data-testid={fieldName ? `select-trigger-${fieldName}` : undefined}
|
|
176
|
-
// AFTER the spread so this widget's own computation wins, the #3222
|
|
177
|
-
// discipline: `error` is the published validation slot
|
|
178
|
-
// (`FieldWidgetPropsSchema`), and `!!undefined` must yield an explicit
|
|
179
|
-
// `"false"` — a valid field SAYS it is valid rather than staying mute.
|
|
180
|
-
aria-invalid={!!error}
|
|
181
|
-
>
|
|
182
|
-
<SelectValue placeholder={config?.placeholder || t('common.selectOption')} />
|
|
183
|
-
</SelectTrigger>
|
|
184
|
-
<SelectContent position="popper">
|
|
185
|
-
{options.map((option) => (
|
|
186
|
-
<SelectItem key={option.value} value={option.value} data-testid={`select-option-${option.value}`}>
|
|
187
|
-
{option.label}
|
|
188
|
-
</SelectItem>
|
|
189
|
-
))}
|
|
190
|
-
</SelectContent>
|
|
191
|
-
</Select>
|
|
192
|
-
);
|
|
193
|
-
}
|
|
@@ -1,110 +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 from 'react';
|
|
10
|
-
import { Avatar, AvatarFallback, AvatarImage, cn } from '@object-ui/components';
|
|
11
|
-
import { X } from 'lucide-react';
|
|
12
|
-
import {
|
|
13
|
-
getPersonName,
|
|
14
|
-
getPersonInitials,
|
|
15
|
-
getPersonAvatarUrl,
|
|
16
|
-
getPersonId,
|
|
17
|
-
} from './personDisplay';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* "Selected" transfer-box area (穿梭框式已选区): live echo of the current
|
|
21
|
-
* multi-selection as removable avatar chips. Reusable kernel — the future
|
|
22
|
-
* org-tree tier composes the same tray beside its right-hand list. Purely
|
|
23
|
-
* presentational: it takes the resolved records + an onRemove callback, and
|
|
24
|
-
* accepts already-translated `label`/`emptyText` so it stays i18n-agnostic.
|
|
25
|
-
*/
|
|
26
|
-
export interface SelectionTrayProps {
|
|
27
|
-
/** Full record objects for the current selection. */
|
|
28
|
-
records: any[];
|
|
29
|
-
/** Remove a selected record by its id. */
|
|
30
|
-
onRemove: (id: any) => void;
|
|
31
|
-
displayField?: string;
|
|
32
|
-
avatarField?: string;
|
|
33
|
-
idField?: string;
|
|
34
|
-
/** Header label, e.g. "Selected (3)" — omit to hide the header. */
|
|
35
|
-
label?: string;
|
|
36
|
-
/** When provided, a "Clear all" action shows in the header while non-empty. */
|
|
37
|
-
onClear?: () => void;
|
|
38
|
-
/** Localized "Clear all" text (i18n-agnostic — caller passes it). */
|
|
39
|
-
clearLabel?: string;
|
|
40
|
-
/** Shown when the selection is empty. */
|
|
41
|
-
emptyText?: string;
|
|
42
|
-
className?: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function SelectionTray({
|
|
46
|
-
records,
|
|
47
|
-
onRemove,
|
|
48
|
-
displayField = 'name',
|
|
49
|
-
avatarField = 'image',
|
|
50
|
-
idField = 'id',
|
|
51
|
-
label,
|
|
52
|
-
onClear,
|
|
53
|
-
clearLabel,
|
|
54
|
-
emptyText,
|
|
55
|
-
className,
|
|
56
|
-
}: SelectionTrayProps) {
|
|
57
|
-
const showHeader = !!label || (!!onClear && records.length > 0);
|
|
58
|
-
return (
|
|
59
|
-
<div className={cn('flex flex-col gap-1.5', className)} data-testid="selection-tray">
|
|
60
|
-
{showHeader && (
|
|
61
|
-
<div className="flex items-center justify-between">
|
|
62
|
-
{label && <div className="text-xs font-medium text-muted-foreground">{label}</div>}
|
|
63
|
-
{onClear && records.length > 0 && (
|
|
64
|
-
<button
|
|
65
|
-
type="button"
|
|
66
|
-
onClick={onClear}
|
|
67
|
-
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
68
|
-
data-testid="selection-clear"
|
|
69
|
-
>
|
|
70
|
-
{clearLabel ?? 'Clear all'}
|
|
71
|
-
</button>
|
|
72
|
-
)}
|
|
73
|
-
</div>
|
|
74
|
-
)}
|
|
75
|
-
{records.length === 0 ? (
|
|
76
|
-
<div className="text-xs text-muted-foreground">{emptyText ?? ''}</div>
|
|
77
|
-
) : (
|
|
78
|
-
<div className="flex flex-wrap gap-1.5">
|
|
79
|
-
{records.map(record => {
|
|
80
|
-
const id = getPersonId(record, idField);
|
|
81
|
-
const name = getPersonName(record, displayField);
|
|
82
|
-
const avatarUrl = getPersonAvatarUrl(record, avatarField);
|
|
83
|
-
const initials = getPersonInitials(name);
|
|
84
|
-
return (
|
|
85
|
-
<span
|
|
86
|
-
key={String(id)}
|
|
87
|
-
data-testid="selection-chip"
|
|
88
|
-
className="inline-flex items-center gap-1.5 rounded-full border bg-background py-0.5 pl-0.5 pr-1.5 text-sm"
|
|
89
|
-
>
|
|
90
|
-
<Avatar className="size-6 shrink-0">
|
|
91
|
-
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
|
|
92
|
-
<AvatarFallback className="text-[10px]">{initials}</AvatarFallback>
|
|
93
|
-
</Avatar>
|
|
94
|
-
<span className="max-w-[10rem] truncate">{name || '—'}</span>
|
|
95
|
-
<button
|
|
96
|
-
type="button"
|
|
97
|
-
onClick={() => onRemove(id)}
|
|
98
|
-
aria-label={`Remove ${name}`}
|
|
99
|
-
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
100
|
-
>
|
|
101
|
-
<X className="size-3.5" aria-hidden />
|
|
102
|
-
</button>
|
|
103
|
-
</span>
|
|
104
|
-
);
|
|
105
|
-
})}
|
|
106
|
-
</div>
|
|
107
|
-
)}
|
|
108
|
-
</div>
|
|
109
|
-
);
|
|
110
|
-
}
|