@object-ui/fields 17.2.0 → 17.3.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.
Files changed (142) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  3. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  4. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  5. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  6. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +2881 -2566
  10. package/dist/widgets/AddressField.d.ts +1 -1
  11. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/CharacterCount.d.ts +56 -0
  14. package/dist/widgets/CheckboxesField.d.ts +1 -1
  15. package/dist/widgets/CodeField.d.ts +1 -1
  16. package/dist/widgets/ColorField.d.ts +1 -1
  17. package/dist/widgets/CurrencyField.d.ts +1 -1
  18. package/dist/widgets/EmailField.d.ts +1 -1
  19. package/dist/widgets/FileField.d.ts +1 -1
  20. package/dist/widgets/FilterConditionField.d.ts +1 -1
  21. package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
  22. package/dist/widgets/GeolocationField.d.ts +1 -1
  23. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  24. package/dist/widgets/ImageField.d.ts +1 -1
  25. package/dist/widgets/LocationField.d.ts +1 -1
  26. package/dist/widgets/LookupField.d.ts +1 -1
  27. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
  28. package/dist/widgets/MultiSelectField.d.ts +1 -1
  29. package/dist/widgets/ObjectField.d.ts +1 -1
  30. package/dist/widgets/ObjectRefField.d.ts +1 -1
  31. package/dist/widgets/OptionsEmptyState.d.ts +45 -0
  32. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  33. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  34. package/dist/widgets/PercentField.d.ts +1 -1
  35. package/dist/widgets/PhoneField.d.ts +1 -1
  36. package/dist/widgets/QRCodeField.d.ts +1 -1
  37. package/dist/widgets/RadioField.d.ts +1 -1
  38. package/dist/widgets/RatingField.d.ts +1 -1
  39. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  40. package/dist/widgets/RecordPickerDialog.d.ts +17 -1
  41. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
  42. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
  43. package/dist/widgets/RichTextField.d.ts +19 -1
  44. package/dist/widgets/TagsField.d.ts +1 -1
  45. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  46. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  47. package/dist/widgets/TextAreaField.d.ts +44 -5
  48. package/dist/widgets/UrlField.d.ts +1 -1
  49. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  50. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  51. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  52. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  53. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  54. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  55. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  56. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  57. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  58. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  59. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  60. package/dist/widgets/toDomProps.d.ts +91 -0
  61. package/dist/widgets/types.d.ts +128 -33
  62. package/dist/withFieldCarrier.d.ts +56 -0
  63. package/package.json +13 -13
  64. package/src/__tests__/cell-truncation.test.tsx +96 -0
  65. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  66. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  67. package/src/__tests__/validation-feedback.test.tsx +22 -13
  68. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  69. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  70. package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
  71. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  72. package/src/index.tsx +74 -26
  73. package/src/widgets/AddressField.tsx +31 -12
  74. package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
  75. package/src/widgets/AvatarField.tsx +9 -2
  76. package/src/widgets/BooleanField.tsx +3 -3
  77. package/src/widgets/CharacterCount.tsx +263 -0
  78. package/src/widgets/CheckboxesField.tsx +31 -14
  79. package/src/widgets/CodeField.tsx +9 -2
  80. package/src/widgets/ColorField.tsx +11 -2
  81. package/src/widgets/CurrencyField.tsx +5 -4
  82. package/src/widgets/DateField.tsx +2 -2
  83. package/src/widgets/DateTimeField.tsx +2 -2
  84. package/src/widgets/EmailField.tsx +5 -5
  85. package/src/widgets/FileField.tsx +13 -3
  86. package/src/widgets/FilterConditionField.tsx +11 -0
  87. package/src/widgets/FormulaField.tsx +1 -1
  88. package/src/widgets/FullscreenFieldEditor.tsx +271 -0
  89. package/src/widgets/GeolocationField.tsx +21 -6
  90. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  91. package/src/widgets/GridField.tsx +1 -1
  92. package/src/widgets/ImageField.tsx +9 -2
  93. package/src/widgets/LocationField.tsx +9 -2
  94. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  95. package/src/widgets/LookupField.tsx +41 -6
  96. package/src/widgets/MasterDetailField.tsx +1 -1
  97. package/src/widgets/MultiSelectField.tsx +30 -14
  98. package/src/widgets/NumberField.tsx +3 -3
  99. package/src/widgets/ObjectField.tsx +17 -8
  100. package/src/widgets/ObjectRefField.tsx +10 -0
  101. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  102. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  103. package/src/widgets/OptionsEmptyState.tsx +83 -0
  104. package/src/widgets/PasswordField.tsx +3 -3
  105. package/src/widgets/PeoplePicker.tsx +7 -1
  106. package/src/widgets/PercentField.tsx +5 -4
  107. package/src/widgets/PhoneField.tsx +5 -5
  108. package/src/widgets/QRCodeField.tsx +9 -2
  109. package/src/widgets/RadioField.tsx +24 -14
  110. package/src/widgets/RatingField.tsx +18 -3
  111. package/src/widgets/RecipientPickerField.tsx +18 -0
  112. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  113. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  114. package/src/widgets/RecordPickerDialog.tsx +173 -8
  115. package/src/widgets/RichTextField.tsx +151 -24
  116. package/src/widgets/SelectField.tsx +48 -17
  117. package/src/widgets/SliderField.tsx +1 -1
  118. package/src/widgets/SummaryField.tsx +1 -1
  119. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  120. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  121. package/src/widgets/TagsField.tsx +23 -2
  122. package/src/widgets/TextAreaField.tsx +171 -93
  123. package/src/widgets/TextField.tsx +3 -3
  124. package/src/widgets/TimeField.tsx +2 -2
  125. package/src/widgets/UrlField.tsx +5 -5
  126. package/src/widgets/UserField.tsx +1 -1
  127. package/src/widgets/VectorField.tsx +1 -1
  128. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  129. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  130. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  131. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  132. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  133. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  134. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  135. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  136. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  137. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  138. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  139. package/src/widgets/toDomProps.ts +175 -0
  140. package/src/widgets/types.ts +135 -35
  141. package/src/widgets/useFieldTranslation.ts +60 -0
  142. package/src/withFieldCarrier.tsx +75 -0
@@ -8,7 +8,7 @@ import { FieldWidgetComponentProps } from './types';
8
8
  */
9
9
  export function SliderField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
10
10
  // Get slider-specific configuration from field metadata
11
- const sliderField = (field || (props as any).schema) as any;
11
+ const sliderField = field as any;
12
12
  const min = sliderField?.min ?? 0;
13
13
  const max = sliderField?.max ?? 100;
14
14
  const step = sliderField?.step ?? 1;
@@ -7,7 +7,7 @@ import { FieldWidgetComponentProps } from './types';
7
7
  * Values are aggregated from related records and cannot be edited
8
8
  */
9
9
  export function SummaryField({ value, field, ...props }: FieldWidgetComponentProps<any>) {
10
- const summaryField = (field || (props as any).schema) as any;
10
+ const summaryField = field as any;
11
11
  const summaryType = summaryField?.summary_type || 'count';
12
12
 
13
13
  if (value == null) {
@@ -0,0 +1,68 @@
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
+ * The tags widget's placeholder with NO `I18nProvider` mounted
11
+ * (objectui#3342) — standalone/embedded usage, and every test in the repo
12
+ * that renders a field widget bare.
13
+ *
14
+ * Routing the copy through `useFieldTranslation()` must not turn it into a
15
+ * raw i18n key when nothing is configured: `createSafeTranslation` probes the
16
+ * instance and falls back to its English defaults map. This is also where the
17
+ * Commandment #-1 pin lives: the copy shipped FROM CODE (the no-provider
18
+ * default) carries no CJK — Chinese exists only in the zh locale pack and can
19
+ * only arrive through a provider.
20
+ *
21
+ * Why a separate FILE rather than another case in
22
+ * `TagsField.placeholder.test.tsx`: mounting `I18nProvider` calls
23
+ * `initReactI18next`, which installs that instance as react-i18next's GLOBAL
24
+ * default. Once any sibling test has done so there is no "no provider" state
25
+ * left to observe in that module graph. Vitest's per-file isolation is what
26
+ * makes this file's observation honest (same split as
27
+ * `OptionsEmptyState.no-provider.test.tsx`).
28
+ */
29
+ import { describe, it, expect, vi } from 'vitest';
30
+ import React from 'react';
31
+ import { render, screen } from '@testing-library/react';
32
+ import '@testing-library/jest-dom';
33
+ import { TagsField } from './TagsField';
34
+
35
+ function renderTags(field: Record<string, unknown>) {
36
+ return render(
37
+ <TagsField
38
+ value={[]}
39
+ onChange={vi.fn()}
40
+ field={field as any}
41
+ {...({ name: 'labels' } as any)}
42
+ />,
43
+ );
44
+ }
45
+
46
+ const input = () => screen.getByRole('textbox');
47
+
48
+ describe('TagsField placeholder — English fallback survives with no i18n configured', () => {
49
+ it('renders the English default, not a raw key', () => {
50
+ renderTags({ name: 'labels', type: 'tags' });
51
+ expect(input()).toHaveAttribute('placeholder', 'Type and press Enter to add…');
52
+ expect(input().getAttribute('placeholder')).not.toContain('fields.tags');
53
+ });
54
+
55
+ it('the code-shipped default carries no CJK (Commandment #-1)', () => {
56
+ renderTags({ name: 'labels', type: 'tags' });
57
+ // With no provider mounted, whatever renders here came from CODE
58
+ // (FIELD_DEFAULTS) — the commandment pin: no Chinese in the codebase.
59
+ // Escaped ranges on purpose: a literal CJK class would itself violate
60
+ // the commandment this test enforces (same shape as objectui#3321's pin).
61
+ expect(input().getAttribute('placeholder')).not.toMatch(/[\u3000-\u30ff\u4e00-\u9fff]/);
62
+ });
63
+
64
+ it('the author-declared field.placeholder still wins without a provider', () => {
65
+ renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' });
66
+ expect(input()).toHaveAttribute('placeholder', 'Add product labels');
67
+ });
68
+ });
@@ -0,0 +1,95 @@
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
+ * The tags widget's input placeholder (objectui#3342).
11
+ *
12
+ * It used to be a hardcoded Chinese literal in code — a double violation:
13
+ * Commandment #-1 (English-only codebase) AND a bypass of the i18n channel the
14
+ * sibling widgets already use. The pinned resolution chain is:
15
+ *
16
+ * 1. `field.placeholder` — the field author's declaration always wins;
17
+ * 2. `t('fields.tags.placeholder')` — the widget's own copy, from the
18
+ * locale packs via `useFieldTranslation()`;
19
+ * 3. the English default in FIELD_DEFAULTS when no I18nProvider is mounted
20
+ * (that leg lives in `TagsField.placeholder.no-provider.test.tsx` — see
21
+ * its header for why it must be a separate file).
22
+ *
23
+ * Chinese now lives in the zh locale pack only, which is what the zh-locale
24
+ * case below observes end-to-end.
25
+ */
26
+ import { describe, it, expect, vi } from 'vitest';
27
+ import React from 'react';
28
+ import { render, screen } from '@testing-library/react';
29
+ import '@testing-library/jest-dom';
30
+ import { I18nProvider } from '@object-ui/i18n';
31
+ import { TagsField } from './TagsField';
32
+
33
+ function renderTags(
34
+ field: Record<string, unknown>,
35
+ { value = [] as string[], language }: { value?: string[]; language?: string } = {},
36
+ ) {
37
+ const element = (
38
+ <TagsField
39
+ value={value}
40
+ onChange={vi.fn()}
41
+ field={field as any}
42
+ {...({ name: 'labels' } as any)}
43
+ />
44
+ );
45
+ return render(
46
+ language ? (
47
+ <I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
48
+ {element}
49
+ </I18nProvider>
50
+ ) : (
51
+ element
52
+ ),
53
+ );
54
+ }
55
+
56
+ const input = () => screen.getByRole('textbox');
57
+
58
+ describe('TagsField placeholder — author declaration beats the widget copy (objectui#3342)', () => {
59
+ it('renders the author-declared field.placeholder', () => {
60
+ renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, { language: 'en' });
61
+ expect(input()).toHaveAttribute('placeholder', 'Add product labels');
62
+ });
63
+
64
+ it('the author declaration wins even under a non-English locale', () => {
65
+ // The author's copy is theirs verbatim — a locale switch must not
66
+ // overwrite an explicit declaration with the widget's own translation.
67
+ renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, { language: 'zh' });
68
+ expect(input()).toHaveAttribute('placeholder', 'Add product labels');
69
+ expect(input().getAttribute('placeholder')).not.toContain('输入后回车添加');
70
+ });
71
+ });
72
+
73
+ describe('TagsField placeholder — undeclared falls back to the translated key', () => {
74
+ it('renders the en pack copy under the en locale', () => {
75
+ renderTags({ name: 'labels', type: 'tags' }, { language: 'en' });
76
+ expect(input()).toHaveAttribute('placeholder', 'Type and press Enter to add…');
77
+ });
78
+
79
+ it('renders the zh pack copy under the zh locale — Chinese comes from the pack, not code', () => {
80
+ renderTags({ name: 'labels', type: 'tags' }, { language: 'zh' });
81
+ expect(input()).toHaveAttribute('placeholder', '输入后回车添加…');
82
+ // …and never the raw key.
83
+ expect(input().getAttribute('placeholder')).not.toContain('fields.tags');
84
+ });
85
+ });
86
+
87
+ describe('TagsField placeholder — shown only while the tag list is empty', () => {
88
+ it('is empty once a tag exists (the input is a continuation strip)', () => {
89
+ renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, {
90
+ value: ['alpha'],
91
+ language: 'en',
92
+ });
93
+ expect(input()).toHaveAttribute('placeholder', '');
94
+ });
95
+ });
@@ -1,15 +1,19 @@
1
1
  import React from 'react';
2
2
  import { Badge, Input, EmptyValue, cn } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
5
+ import { useFieldTranslation } from './useFieldTranslation';
4
6
 
5
7
  /**
6
8
  * TagsField - free-form list of string tags. Type a value and press Enter (or
7
9
  * comma) to add it; click a tag's × to remove it. The stored value is a
8
10
  * string[]. Used for the `tags` field type.
9
11
  */
10
- export function TagsField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps<string[]>) {
12
+ export function TagsField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps<string[]>) {
11
13
  const tags: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
12
14
  const [draft, setDraft] = React.useState('');
15
+ // Unconditional hook call (rules-of-hooks): before the readonly early return.
16
+ const { t } = useFieldTranslation();
13
17
 
14
18
  if (readonly) {
15
19
  if (tags.length === 0) return <EmptyValue />;
@@ -54,13 +58,30 @@ export function TagsField({ value, onChange, field, readonly, className, ...prop
54
58
  </Badge>
55
59
  ))}
56
60
  <Input
61
+ // DOM pass-through onto the real focusable control (objectui#3318):
62
+ // the whitelist spread carries the form renderer's id /
63
+ // aria-describedby to the input the user actually types in.
64
+ {...toDomProps(props)}
57
65
  value={draft}
58
66
  onChange={(e) => setDraft(e.target.value)}
59
67
  onKeyDown={onKeyDown}
60
68
  onBlur={() => addTag(draft)}
61
69
  disabled={props.disabled}
62
- placeholder={tags.length === 0 ? '输入后回车添加…' : ''}
70
+ // Placeholder chain (objectui#3342): the author-declared
71
+ // `field.placeholder` wins; otherwise the widget's own copy arrives
72
+ // via `useFieldTranslation()` — `fields.tags.placeholder` in the
73
+ // locale packs, English default from FIELD_DEFAULTS when no
74
+ // I18nProvider is mounted. This used to be a hardcoded Chinese
75
+ // literal (Commandment #-1); Chinese now lives in the zh pack only.
76
+ // Shown only while the list is empty — once a tag exists the input is
77
+ // a small continuation strip and the hint would be noise.
78
+ placeholder={tags.length === 0 ? field?.placeholder || t('fields.tags.placeholder') : ''}
63
79
  className="h-7 flex-1 border-0 bg-transparent p-0 px-1 shadow-none focus-visible:ring-0 min-w-[8ch]"
80
+ // AFTER the spread so this widget's own computation wins (the #3222
81
+ // discipline): `error` is the published validation slot, and
82
+ // `!!undefined` yields an explicit "false" — a valid field SAYS it is
83
+ // valid rather than staying mute.
84
+ aria-invalid={!!error}
64
85
  />
65
86
  </div>
66
87
  );
@@ -1,30 +1,84 @@
1
- import React, { useState } from 'react';
2
- import {
3
- Textarea,
4
- EmptyValue,
5
- Dialog,
6
- DialogContent,
7
- DialogHeader,
8
- DialogTitle,
9
- DialogFooter,
10
- Button,
11
- } from '@object-ui/components';
12
- import { Maximize2, Check, X } from 'lucide-react';
1
+ import React, { useId } from 'react';
2
+ import { Textarea, EmptyValue } from '@object-ui/components';
3
+ import { CharacterCount } from './CharacterCount';
4
+ import { FullscreenFieldEditor } from './FullscreenFieldEditor';
13
5
  import { FieldWidgetComponentProps } from './types';
6
+ import { toDomProps } from './toDomProps';
14
7
 
15
8
  /**
16
9
  * TextAreaField - Multi-line text input widget
17
10
  * Supports configurable row count and preserves whitespace in readonly mode.
18
11
  *
19
- * Mobile UX (round 3): when the host form passes `mobileFullscreen` (or the
20
- * field schema sets `mobile_fullscreen: true`), an "expand" affordance opens
21
- * a fullscreen edit dialog — much easier on phones than tapping a 4-row
22
- * textarea trapped between other fields.
12
+ * Mobile UX (round 3): when the FIELD METADATA carries `mobile_fullscreen:
13
+ * true`, an "expand" affordance opens a fullscreen edit dialog — much easier
14
+ * on phones than tapping a 4-row textarea trapped between other fields.
15
+ *
16
+ * That flag has exactly one producer: `ObjectForm` stamps it onto every
17
+ * long-text field when `ObjectFormSchema.mobile.fullscreenLongText` is set
18
+ * (`plugin-form/src/ObjectForm.tsx`). It reaches this widget on `field` — the
19
+ * single metadata carrier since objectui#3233. A `SchemaRenderer`-hosted node
20
+ * arrives there too: the registry adapter (`withFieldCarrier`) maps the SDUI
21
+ * `schema` node onto `field` before the widget sees it.
22
+ *
23
+ * The affordance, the dialog and the draft/commit semantics live in the shared
24
+ * `FullscreenFieldEditor` — the same producer stamps the same flag on
25
+ * rich-text fields, and `RichTextField` renders it from there too
26
+ * (objectui#3301). Only the EDITOR differs per widget; here it is a
27
+ * full-height `Textarea`.
28
+ *
29
+ * There is deliberately NO widget-prop override. A `mobileFullscreen`
30
+ * (camelCase) prop was read here and written by nobody in the repo, and the
31
+ * snake_case `mobile_fullscreen` prop cannot arrive either: the form renderer
32
+ * strips both `mobile_fullscreen` and `fullscreen` from the props it forwards
33
+ * to registered widgets (`stripRegisteredFieldProps` in
34
+ * `components/src/renderers/form/form.tsx`). Reading keys nobody produces
35
+ * documented a contract that never held and invited the next author to pass a
36
+ * silently-ignored prop, so the reads are gone (objectui#3232). If a host
37
+ * override is ever genuinely needed, declare ONE key on
38
+ * `FieldWidgetComponentProps`, stop stripping it, and have a host pass it.
39
+ *
40
+ * ## The character counter (objectui#3408, shared across surfaces in #3417)
41
+ *
42
+ * Both editing surfaces below — the inline `<Textarea>` and the fullscreen
43
+ * dialog's — render the SAME `CharacterCount`, which owns the three-node
44
+ * GOV.UK shape (decorative digits / accessible description / gated status
45
+ * region) and the debounce and threshold behind it. Until #3417 the fullscreen
46
+ * footer had its own hand-written copy that was digits and nothing else, which
47
+ * is what a second copy of a UI always costs: it drifts, and nothing reports
48
+ * the drift.
49
+ *
50
+ * This widget's remaining job is the part only it can do — mint the two
51
+ * description ids and name each one in the `aria-describedby` of the control
52
+ * it describes. The surfaces differ in exactly one behaviour, and it is
53
+ * declared rather than implied: `announceNearLimit`. Inline announces
54
+ * (unchanged since #3408); the fullscreen dialog does not.
23
55
  */
24
- export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
25
- // Hooks must run before any early return (readonly) to keep hook order stable.
26
- const [fullscreenOpen, setFullscreenOpen] = useState(false);
27
- const [draft, setDraft] = useState(value ?? '');
56
+
57
+ export function TextAreaField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
58
+ // `useId` sits above the `readonly` early return because a hook may not sit
59
+ // behind a conditional return: the readonly branch renders no counter, so
60
+ // the ids go unused there, but moving the call down would desync hook order
61
+ // the moment a field toggles readonly. `maxLength` is derived here for the
62
+ // same reason the ids are — proximity to the hook, not because the readonly
63
+ // branch wants it.
64
+ const textareaField = field as any;
65
+ // Spec FieldSchema declares camelCase `maxLength`; `max_length` is the legacy
66
+ // objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a
67
+ // spec-authored maxLength gave neither the textarea cap nor the counter.
68
+ const maxLength = textareaField?.maxLength ?? textareaField?.max_length;
69
+
70
+ /**
71
+ * Two description ids off one `useId()` — one per editing surface.
72
+ *
73
+ * They cannot be a single id. The dialog edits a LOCAL draft (see
74
+ * `FullscreenFieldEditor`), so the moment the user types in it the two
75
+ * surfaces are counting different strings; one shared id would point both
76
+ * textareas at whichever sentence rendered last. Both derive from the widget
77
+ * instance, so two textareas on one form never collide either.
78
+ */
79
+ const instanceId = useId();
80
+ const descriptionId = `${instanceId}-charcount`;
81
+ const fullscreenDescriptionId = `${instanceId}-fullscreen-charcount`;
28
82
 
29
83
  if (readonly) {
30
84
  return (
@@ -34,27 +88,29 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
34
88
  );
35
89
  }
36
90
 
37
- const textareaField = (field || (props as any).schema) as any;
38
91
  const rows = textareaField?.rows || 4;
39
- // Spec FieldSchema declares camelCase `maxLength`; `max_length` is the legacy
40
- // objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a
41
- // spec-authored maxLength gave neither the textarea cap nor the counter.
42
- const maxLength = textareaField?.maxLength ?? textareaField?.max_length;
43
- // Mobile fullscreen flag may arrive on the field metadata, on the form-field
44
- // schema (when called via the form renderer where `field` is the ObjectQL
45
- // metadata sub-object), or as an explicit widget prop.
46
- const showFullscreenButton = Boolean(
47
- (props as any).mobileFullscreen ??
48
- textareaField?.mobile_fullscreen ??
49
- (props as any).mobile_fullscreen ??
50
- (props as any).schema?.mobile_fullscreen,
51
- );
92
+ // Mobile fullscreen opt-in travels on the field metadata and nowhere else.
93
+ // That metadata has exactly one carrier (`field`, objectui#3233), so this is
94
+ // a single read — a misspelled flag has no read path to quietly catch it.
95
+ const showFullscreenButton = Boolean(textareaField?.mobile_fullscreen);
52
96
 
53
- const openFullscreen = () => { setDraft(value ?? ''); setFullscreenOpen(true); };
54
- const cancelFullscreen = () => setFullscreenOpen(false);
55
- const commitFullscreen = () => { onChange(draft); setFullscreenOpen(false); };
97
+ const domProps = toDomProps(props);
98
+ // Resolved once and given to BOTH editing surfaces (objectui#3402). It used
99
+ // to reach the inline `<Textarea>` alone, so a disabled field greyed out
100
+ // correctly while its expand button stayed live and the dialog wrote the edit
101
+ // straight back through `onCommit`. `disabled` also carries the form's
102
+ // `isSubmitting`, so that hole was open for the duration of every submit.
103
+ const disabled = Boolean(domProps.disabled);
56
104
 
57
- const { inputType, mobileFullscreen, ...domProps } = props as any;
105
+ // APPENDED, never assigned (objectui#3408). `<FormControl>` is a Radix Slot
106
+ // and already hands the control an `aria-describedby` naming the field's
107
+ // description and error message; it arrives here through `toDomProps`'
108
+ // `aria-*` pass-through. Overwriting it would trade "no cap announced" for
109
+ // "no error announced" — a strictly worse bug, and a silent one.
110
+ const describedBy =
111
+ [domProps['aria-describedby'], maxLength ? descriptionId : undefined]
112
+ .filter(Boolean)
113
+ .join(' ') || undefined;
58
114
 
59
115
  return (
60
116
  <div className="relative">
@@ -63,72 +119,94 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
63
119
  value={value || ''}
64
120
  onChange={(e) => onChange(e.target.value)}
65
121
  placeholder={textareaField?.placeholder}
66
- disabled={readonly || domProps.disabled}
122
+ disabled={readonly || disabled}
67
123
  rows={rows}
68
124
  maxLength={maxLength}
69
- aria-invalid={!!errorMessage}
125
+ aria-invalid={!!error}
126
+ // After the spread so the composed value wins over the raw host one.
127
+ aria-describedby={describedBy}
70
128
  className={domProps.className}
71
129
  />
72
- {showFullscreenButton && (
73
- <button
74
- type="button"
75
- onClick={openFullscreen}
76
- className="absolute top-1.5 right-1.5 inline-flex items-center justify-center size-7 rounded-md bg-background/80 text-muted-foreground hover:text-foreground hover:bg-background border shadow-sm transition-colors"
77
- aria-label={`Edit ${textareaField?.label ?? 'text'} fullscreen`}
78
- data-testid="textarea-fullscreen-toggle"
79
- >
80
- <Maximize2 className="size-3.5" />
81
- </button>
82
- )}
83
130
  {maxLength && (
84
- <div
131
+ /*
132
+ The INLINE surface's counter. `announceNearLimit` because this is the
133
+ surface the user types into with the rest of the form around them —
134
+ the near-limit warning is the one thing worth interrupting for, and
135
+ it is threshold-gated and debounced so it interrupts about five times
136
+ over a run that fills a 500-character cap rather than 52.
137
+ */
138
+ <CharacterCount
139
+ length={(value || '').length}
140
+ maxLength={maxLength}
141
+ descriptionId={descriptionId}
142
+ announceNearLimit
85
143
  className="absolute bottom-2 right-2 text-xs text-gray-400"
86
- aria-live="polite"
87
- aria-label={`Character count: ${(value || '').length} of ${maxLength}`}
88
- >
89
- {(value || '').length}/{maxLength}
90
- </div>
144
+ testId="textarea-character-count"
145
+ />
91
146
  )}
92
147
 
93
148
  {showFullscreenButton && (
94
- <Dialog open={fullscreenOpen} onOpenChange={setFullscreenOpen}>
95
- <DialogContent
96
- className="sm:max-w-3xl h-[100dvh] sm:h-[80vh] max-h-[100dvh] sm:max-h-[80vh] flex flex-col p-0 gap-0"
97
- data-testid="textarea-fullscreen-dialog"
98
- >
99
- <DialogHeader className="p-4 border-b">
100
- <DialogTitle className="text-base">
101
- {textareaField?.label ?? 'Edit text'}
102
- </DialogTitle>
103
- </DialogHeader>
104
- <div className="flex-1 min-h-0 p-4">
105
- <Textarea
106
- autoFocus
107
- value={draft}
108
- onChange={(e) => setDraft(e.target.value)}
149
+ <FullscreenFieldEditor
150
+ value={value ?? ''}
151
+ onCommit={onChange}
152
+ label={textareaField?.label}
153
+ testIdPrefix="textarea"
154
+ disabled={disabled}
155
+ /*
156
+ The FULLSCREEN surface's counter (objectui#3417). This slot used to
157
+ render a bare `{n}/{max}` span: no accessible name, no association
158
+ with the dialog's textarea, nothing `aria-live`. A screen reader
159
+ heard "5 slash 500" if browse mode happened to sweep it and not one
160
+ word while the input had focus — the same field, the same cap, and
161
+ the inline surface three nodes richer since objectui#3408.
162
+
163
+ `announceNearLimit={false}` is the ruling on #3417, and it is a
164
+ choice about this SURFACE rather than a saving on effort: a
165
+ fullscreen modal is opened deliberately to write at length, the
166
+ description below already delivers the cap on focus, and the
167
+ dialog's textarea carries the same native `maxLength` stop. Adding
168
+ a second live region inside a modal would also put two of them in
169
+ one document, since the inline one stays mounted behind the overlay.
170
+ */
171
+ footer={(draft) =>
172
+ maxLength ? (
173
+ <CharacterCount
174
+ length={draft.length}
109
175
  maxLength={maxLength}
110
- placeholder={textareaField?.placeholder}
111
- className="h-full min-h-full resize-none text-base"
112
- data-testid="textarea-fullscreen-input"
176
+ descriptionId={fullscreenDescriptionId}
177
+ announceNearLimit={false}
178
+ className="text-xs text-muted-foreground self-center"
179
+ testId="textarea-fullscreen-character-count"
113
180
  />
114
- </div>
115
- <DialogFooter className="p-3 border-t flex-row justify-between sm:justify-end gap-2">
116
- {maxLength && (
117
- <span className="text-xs text-muted-foreground self-center">
118
- {draft.length}/{maxLength}
119
- </span>
120
- )}
121
- <div className="flex gap-2 ml-auto">
122
- <Button type="button" variant="ghost" onClick={cancelFullscreen}>
123
- <X className="size-4 mr-1" /> Cancel
124
- </Button>
125
- <Button type="button" onClick={commitFullscreen} data-testid="textarea-fullscreen-save">
126
- <Check className="size-4 mr-1" /> Done
127
- </Button>
128
- </div>
129
- </DialogFooter>
130
- </DialogContent>
131
- </Dialog>
181
+ ) : null
182
+ }
183
+ >
184
+ {(draft, setDraft, editorDisabled) => (
185
+ <Textarea
186
+ autoFocus
187
+ value={draft}
188
+ onChange={(e) => setDraft(e.target.value)}
189
+ disabled={editorDisabled}
190
+ maxLength={maxLength}
191
+ placeholder={textareaField?.placeholder}
192
+ /*
193
+ Assigned, not appended — and that is a statement about THIS
194
+ element, not a relaxation of the rule above. The inline control
195
+ composes because `<FormControl>` hands it an `aria-describedby`
196
+ through `domProps`; this one is constructed here from scratch,
197
+ `domProps` never reaches it, and the ids the host would have
198
+ supplied name nodes OUTSIDE the dialog, which Radix `aria-hidden`s
199
+ while the modal is open. There is nothing to preserve. Should
200
+ this editor ever be given host-supplied ids, compose them the
201
+ way `describedBy` above does rather than adding a second
202
+ assignment.
203
+ */
204
+ aria-describedby={maxLength ? fullscreenDescriptionId : undefined}
205
+ className="h-full min-h-full resize-none text-base"
206
+ data-testid="textarea-fullscreen-input"
207
+ />
208
+ )}
209
+ </FullscreenFieldEditor>
132
210
  )}
133
211
  </div>
134
212
  );
@@ -2,13 +2,14 @@ import React from 'react';
2
2
  import { Input, Textarea, EmptyValue } from '@object-ui/components';
3
3
  import { TextareaFieldMetadata } from '@object-ui/types';
4
4
  import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
5
6
 
6
7
  /**
7
8
  * TextField - Standard single-line or multi-line text input
8
9
  * Automatically renders as a textarea when rows are configured in field metadata
9
10
  */
10
11
  export function TextField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
- const fieldData = field || (props as any).schema;
12
+ const fieldData = field;
12
13
 
13
14
  if (readonly) {
14
15
  return <span className="text-sm">{value || <EmptyValue />}</span>;
@@ -17,8 +18,7 @@ export function TextField({ value, onChange, field, readonly, ...props }: FieldW
17
18
  // Cast for rows property
18
19
  const rows = (fieldData as unknown as TextareaFieldMetadata)?.rows;
19
20
 
20
- // Filter out non-DOM props
21
- const { inputType, ...domProps } = props as any;
21
+ const domProps = toDomProps(props);
22
22
 
23
23
  if (rows && rows > 1) {
24
24
  return (
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
4
5
  import { openNativePicker } from './openNativePicker';
5
6
 
6
7
  /**
@@ -12,8 +13,7 @@ export function TimeField({ value, onChange, field, readonly, ...props }: FieldW
12
13
  return <span className="text-sm">{value || <EmptyValue />}</span>;
13
14
  }
14
15
 
15
- // Filter out non-DOM props
16
- const { inputType, ...domProps } = props as any;
16
+ const domProps = toDomProps(props);
17
17
 
18
18
  return (
19
19
  <Input
@@ -1,13 +1,14 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
4
5
 
5
6
  /**
6
7
  * UrlField - URL input with clickable link in readonly mode
7
8
  * Validates URLs to only render http/https links for security
8
9
  */
9
- export function UrlField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
10
- const config = field || (props as any).schema;
10
+ export function UrlField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
11
+ const config = field;
11
12
  if (readonly) {
12
13
  if (!value) return <EmptyValue />;
13
14
 
@@ -29,8 +30,7 @@ export function UrlField({ value, onChange, field, readonly, errorMessage, ...pr
29
30
  );
30
31
  }
31
32
 
32
- // Filter out non-DOM props
33
- const { inputType, ...domProps } = props as any;
33
+ const domProps = toDomProps(props);
34
34
 
35
35
  return (
36
36
  <Input
@@ -40,7 +40,7 @@ export function UrlField({ value, onChange, field, readonly, errorMessage, ...pr
40
40
  onChange={(e) => onChange(e.target.value)}
41
41
  placeholder={config?.placeholder || 'https://example.com'}
42
42
  disabled={readonly || domProps.disabled}
43
- aria-invalid={!!errorMessage}
43
+ aria-invalid={!!error}
44
44
  />
45
45
  );
46
46
  }
@@ -30,7 +30,7 @@ function withBannedFilter(filters?: any[]): any[] {
30
30
  }
31
31
 
32
32
  export function UserField(props: FieldWidgetComponentProps<any>) {
33
- const raw = (props.field || (props as any).schema) as any;
33
+ const raw = props.field as any;
34
34
 
35
35
  // The objectSchema field metadata may live directly on `field`, or nested at
36
36
  // `field.field` when rendered via the createFieldRenderer wrapper — mirror the
@@ -7,7 +7,7 @@ import { FieldWidgetComponentProps } from './types';
7
7
  * Shows vector embeddings in a read-only format
8
8
  */
9
9
  export function VectorField({ value, field, ...props }: FieldWidgetComponentProps<number[]>) {
10
- const vectorField = (field || (props as any).schema) as any;
10
+ const vectorField = field as any;
11
11
  const dimensions = vectorField?.dimensions || (Array.isArray(value) ? value.length : 0);
12
12
 
13
13
  if (!value || !Array.isArray(value)) {