@object-ui/fields 11.5.0 → 13.2.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.
@@ -0,0 +1,11 @@
1
+ import { default as React } from 'react';
2
+ import { FieldWidgetProps } from './types';
3
+ /**
4
+ * Parse the stored value into the selected capability-name list. The canonical
5
+ * storage is a JSON-string array; we also tolerate an already-parsed array (in
6
+ * case a caller hands us the parsed value) and a bare comma-separated string
7
+ * (legacy/hand-authored) so no selection is silently dropped.
8
+ */
9
+ export declare function parseCapabilityNames(value: unknown): string[];
10
+ export declare function CapabilityMultiSelectField({ value, onChange, readonly, className, ...props }: FieldWidgetProps<string | string[]>): React.JSX.Element;
11
+ export default CapabilityMultiSelectField;
File without changes
@@ -1,7 +1,14 @@
1
1
  import { default as React } from 'react';
2
2
  import { FieldWidgetProps } from './types';
3
3
  /**
4
- * SelectField - Dropdown selection widget with configurable options
5
- * Renders options from field metadata with support for placeholder and readonly display
4
+ * SelectField - Dropdown selection widget with configurable options.
5
+ *
6
+ * Supports cascading / role-gated options (#2284): each option may carry a
7
+ * `visibleWhen` CEL predicate, evaluated against the live form record +
8
+ * `current_user`, so the offered set narrows as a controlling field changes
9
+ * (`record.country == 'cn'`) or by role (`'admin' in current_user.roles`). A
10
+ * field declares which sibling fields drive its list via `dependsOn`; while any
11
+ * of those is empty the control is gated with a "select the parent first" hint,
12
+ * mirroring the dependent-lookup UX.
6
13
  */
7
- export declare function SelectField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>): React.JSX.Element;
14
+ export declare function SelectField({ value, onChange, field, readonly, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string>): React.JSX.Element;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Open the browser's native picker (date / datetime-local / time) for an input.
3
+ *
4
+ * Native date/time inputs only open their picker when the user clicks the small
5
+ * calendar/clock icon. Calling `showPicker()` on click makes the whole field
6
+ * behave like other widgets — clicking anywhere in the box opens the picker.
7
+ *
8
+ * `showPicker()` can throw (unsupported browser, not user-activated, or the input
9
+ * is disabled/hidden), so failures are swallowed and the native fallback stands.
10
+ */
11
+ export declare function openNativePicker(input: HTMLInputElement): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/fields",
3
- "version": "11.5.0",
3
+ "version": "13.2.0",
4
4
  "description": "Field renderers and registry for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,12 +30,12 @@
30
30
  "rehype-sanitize": "^6.0.0",
31
31
  "remark-gfm": "^4.0.1",
32
32
  "tailwind-merge": "^3.6.0",
33
- "@object-ui/components": "11.5.0",
34
- "@object-ui/core": "11.5.0",
35
- "@object-ui/i18n": "11.5.0",
36
- "@object-ui/providers": "11.5.0",
37
- "@object-ui/react": "11.5.0",
38
- "@object-ui/types": "11.5.0"
33
+ "@object-ui/components": "13.2.0",
34
+ "@object-ui/core": "13.2.0",
35
+ "@object-ui/i18n": "13.2.0",
36
+ "@object-ui/providers": "13.2.0",
37
+ "@object-ui/react": "13.2.0",
38
+ "@object-ui/types": "13.2.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": "^18.0.0 || ^19.0.0",
@@ -46,7 +46,7 @@
46
46
  "@types/react-dom": "19.2.3",
47
47
  "@vitejs/plugin-react": "^6.0.3",
48
48
  "typescript": "^6.0.3",
49
- "vite": "^8.1.0",
49
+ "vite": "^8.1.3",
50
50
  "vite-plugin-dts": "^5.0.3"
51
51
  },
52
52
  "publishConfig": {
package/src/index.tsx CHANGED
@@ -27,7 +27,7 @@ const lookupNameCache: Map<string, LookupCacheEntry> = new Map();
27
27
  * Pick the most reasonable display name from an arbitrary record object.
28
28
  * Tries common name-like keys in priority order, then falls back to undefined.
29
29
  */
30
- function pickRecordDisplayName(
30
+ export function pickRecordDisplayName(
31
31
  record: Record<string, unknown> | null | undefined,
32
32
  preferredField?: string,
33
33
  ): string | undefined {
@@ -179,6 +179,7 @@ import { CurrencyField } from './widgets/CurrencyField';
179
179
  import { TextAreaField } from './widgets/TextAreaField';
180
180
  import { RichTextField } from './widgets/RichTextField';
181
181
  import { LookupField } from './widgets/LookupField';
182
+ import { CapabilityMultiSelectField } from './widgets/CapabilityMultiSelectField';
182
183
  import { DateTimeField } from './widgets/DateTimeField';
183
184
  import { TimeField } from './widgets/TimeField';
184
185
  import { PercentField } from './widgets/PercentField';
@@ -1450,7 +1451,13 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme
1450
1451
  } else {
1451
1452
  text = String(value);
1452
1453
  }
1453
- return <span className="font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
1454
+ // inline-block + max-w-full so `truncate` (overflow-hidden/ellipsis/nowrap)
1455
+ // actually clamps to the cell width. On a bare inline <span> truncate never
1456
+ // clips — there is no width box — and its `white-space:nowrap` also defeats
1457
+ // the parent cell's `break-words`, so a long name-keyed map / address JSON
1458
+ // spills into the neighbouring column (objectui#2578). The title keeps the
1459
+ // full value on hover.
1460
+ return <span className="block max-w-full font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
1454
1461
  }
1455
1462
 
1456
1463
  /**
@@ -1760,25 +1767,36 @@ export function formatFileSize(bytes: number): string {
1760
1767
  export function buildValidationRules(field: any): any {
1761
1768
  const rules: any = {};
1762
1769
 
1763
- // Required validation
1770
+ // Required validation. Emit a bare `true` for the auto-generated case rather
1771
+ // than baking an English message here: the form renderer localizes it via
1772
+ // `t('validation.required', { field })`. A field-authored `required_message`
1773
+ // still wins and is passed through verbatim.
1764
1774
  if (field.required) {
1765
- rules.required = typeof field.required_message === 'string'
1766
- ? field.required_message
1767
- : `${field.label || field.name} is required`;
1775
+ rules.required = typeof field.required_message === 'string'
1776
+ ? field.required_message
1777
+ : true;
1768
1778
  }
1769
1779
 
1780
+ // Standard validation messages are localized by the form renderer, which has
1781
+ // an i18n `t` in scope. We therefore emit `message: undefined` for the
1782
+ // auto-generated case and tag each rule with a `messageKey` (+ any interp
1783
+ // vars); a field-authored `*_message` is a string and passes through as-is,
1784
+ // still winning over the localized default. See form.tsx `localizeRule`.
1785
+
1770
1786
  // Length validation for text fields
1771
1787
  if (field.min_length) {
1772
1788
  rules.minLength = {
1773
1789
  value: field.min_length,
1774
- message: field.min_length_message || `Minimum length is ${field.min_length} characters`,
1790
+ message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
1791
+ messageKey: 'validation.minLength',
1775
1792
  };
1776
1793
  }
1777
1794
 
1778
1795
  if (field.max_length) {
1779
1796
  rules.maxLength = {
1780
1797
  value: field.max_length,
1781
- message: field.max_length_message || `Maximum length is ${field.max_length} characters`,
1798
+ message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
1799
+ messageKey: 'validation.maxLength',
1782
1800
  };
1783
1801
  }
1784
1802
 
@@ -1786,14 +1804,16 @@ export function buildValidationRules(field: any): any {
1786
1804
  if (field.min !== undefined) {
1787
1805
  rules.min = {
1788
1806
  value: field.min,
1789
- message: field.min_message || `Minimum value is ${field.min}`,
1807
+ message: typeof field.min_message === 'string' ? field.min_message : undefined,
1808
+ messageKey: 'validation.min',
1790
1809
  };
1791
1810
  }
1792
1811
 
1793
1812
  if (field.max !== undefined) {
1794
1813
  rules.max = {
1795
1814
  value: field.max,
1796
- message: field.max_message || `Maximum value is ${field.max}`,
1815
+ message: typeof field.max_message === 'string' ? field.max_message : undefined,
1816
+ messageKey: 'validation.max',
1797
1817
  };
1798
1818
  }
1799
1819
 
@@ -1801,7 +1821,8 @@ export function buildValidationRules(field: any): any {
1801
1821
  if (field.pattern) {
1802
1822
  rules.pattern = {
1803
1823
  value: typeof field.pattern === 'string' ? new RegExp(field.pattern) : field.pattern,
1804
- message: field.pattern_message || 'Invalid format',
1824
+ message: typeof field.pattern_message === 'string' ? field.pattern_message : undefined,
1825
+ messageKey: 'validation.pattern',
1805
1826
  };
1806
1827
  }
1807
1828
 
@@ -1809,7 +1830,8 @@ export function buildValidationRules(field: any): any {
1809
1830
  if (field.type === 'email') {
1810
1831
  rules.pattern = {
1811
1832
  value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
1812
- message: 'Please enter a valid email address',
1833
+ message: undefined,
1834
+ messageKey: 'validation.email',
1813
1835
  };
1814
1836
  }
1815
1837
 
@@ -1817,7 +1839,8 @@ export function buildValidationRules(field: any): any {
1817
1839
  if (field.type === 'url') {
1818
1840
  rules.pattern = {
1819
1841
  value: /^https?:\/\/.+/,
1820
- message: 'Please enter a valid URL',
1842
+ message: undefined,
1843
+ messageKey: 'validation.url',
1821
1844
  };
1822
1845
  }
1823
1846
 
@@ -2130,6 +2153,12 @@ export function registerFields() {
2130
2153
  // display widget remains the default for { type: 'html', content }.
2131
2154
  ComponentRegistry.register('html', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
2132
2155
  ComponentRegistry.register('lookup', createFieldRenderer(LookupField), { namespace: 'field' });
2156
+ // Capability multi-select (ADR-0056 P2) — reached only via the field
2157
+ // `widget: 'capability-multiselect'` hint (see MetadataProvider), never a bare
2158
+ // field type. Registered WITHOUT createFieldRenderer: it is a fully-controlled
2159
+ // widget (value/onChange from RHF), so the wrapper's extra label + local value
2160
+ // buffer would only duplicate the form's FormLabel and desync the value.
2161
+ ComponentRegistry.register('capability-multiselect', CapabilityMultiSelectField, { namespace: 'field', skipFallback: true });
2133
2162
  // master_detail = child-side FK lookup (single value, typically required). See
2134
2163
  // fieldWidgetMap above for rationale.
2135
2164
  ComponentRegistry.register('master_detail', createFieldRenderer(LookupField), { namespace: 'field' });
@@ -2190,6 +2219,7 @@ export * from './widgets/PasswordField';
2190
2219
  export * from './widgets/TextAreaField';
2191
2220
  export * from './widgets/RichTextField';
2192
2221
  export * from './widgets/LookupField';
2222
+ export * from './widgets/CapabilityMultiSelectField';
2193
2223
  export * from './widgets/RecordPickerDialog';
2194
2224
  export * from './widgets/FileField';
2195
2225
  export * from './widgets/ImageField';
@@ -0,0 +1,143 @@
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
+ * CapabilityMultiSelectField (ADR-0056 P2, epic #2398).
11
+ *
12
+ * Proves the two properties P2 depends on:
13
+ * 1. the stored value parses tolerantly into a capability-name list, and
14
+ * 2. every edit emits a JSON-**string** array of names — byte-equivalent to
15
+ * the `sys_permission_set.system_permissions` textarea storage, so the
16
+ * round-trip through the picker never changes the on-disk shape.
17
+ */
18
+ import { describe, it, expect, vi } from 'vitest';
19
+ import React from 'react';
20
+ import { render, screen, fireEvent } from '@testing-library/react';
21
+ import '@testing-library/jest-dom';
22
+ import { CapabilityMultiSelectField, parseCapabilityNames } from './CapabilityMultiSelectField';
23
+
24
+ describe('parseCapabilityNames', () => {
25
+ it('parses the canonical JSON-string array', () => {
26
+ expect(parseCapabilityNames('["setup.access","studio.access"]')).toEqual([
27
+ 'setup.access',
28
+ 'studio.access',
29
+ ]);
30
+ });
31
+
32
+ it('treats empty / null / undefined as no selection', () => {
33
+ expect(parseCapabilityNames('')).toEqual([]);
34
+ expect(parseCapabilityNames(' ')).toEqual([]);
35
+ expect(parseCapabilityNames(null)).toEqual([]);
36
+ expect(parseCapabilityNames(undefined)).toEqual([]);
37
+ expect(parseCapabilityNames('[]')).toEqual([]);
38
+ });
39
+
40
+ it('tolerates an already-parsed array', () => {
41
+ expect(parseCapabilityNames(['a', 'b'])).toEqual(['a', 'b']);
42
+ });
43
+
44
+ it('falls back to comma-splitting a non-JSON legacy string', () => {
45
+ expect(parseCapabilityNames('setup.access, studio.access')).toEqual([
46
+ 'setup.access',
47
+ 'studio.access',
48
+ ]);
49
+ });
50
+ });
51
+
52
+ const CAPS = [
53
+ { name: 'manage_users', label: 'Manage Users', description: 'Manage users', scope: 'platform', active: true },
54
+ { name: 'studio.access', label: 'Studio Access', description: 'Enter Studio', scope: 'platform', active: true },
55
+ { name: 'manage_org_users', label: 'Manage Org Users', description: 'Org members', scope: 'org', active: true },
56
+ ];
57
+
58
+ function mockDataSource(rows = CAPS) {
59
+ return { find: vi.fn().mockResolvedValue({ data: rows }) } as any;
60
+ }
61
+
62
+ describe('CapabilityMultiSelectField', () => {
63
+ it('renders capabilities grouped by scope with human labels', async () => {
64
+ render(
65
+ <CapabilityMultiSelectField
66
+ value={'["studio.access"]'}
67
+ onChange={vi.fn()}
68
+ field={{ name: 'system_permissions' } as any}
69
+ dataSource={mockDataSource()}
70
+ />,
71
+ );
72
+ // group headers
73
+ expect(await screen.findByText('Platform')).toBeInTheDocument();
74
+ expect(screen.getByText('Organization')).toBeInTheDocument();
75
+ // labels, not raw names
76
+ expect(screen.getByRole('button', { name: 'Studio Access' })).toHaveAttribute('aria-pressed', 'true');
77
+ expect(screen.getByRole('button', { name: 'Manage Users' })).toHaveAttribute('aria-pressed', 'false');
78
+ });
79
+
80
+ it('emits a JSON-string array when a capability is toggled on', async () => {
81
+ const onChange = vi.fn();
82
+ render(
83
+ <CapabilityMultiSelectField
84
+ value={'["studio.access"]'}
85
+ onChange={onChange}
86
+ field={{ name: 'system_permissions' } as any}
87
+ dataSource={mockDataSource()}
88
+ />,
89
+ );
90
+ fireEvent.click(await screen.findByRole('button', { name: 'Manage Users' }));
91
+ expect(onChange).toHaveBeenCalledTimes(1);
92
+ const emitted = onChange.mock.calls[0][0];
93
+ // The emitted value must be a JSON *string* (not a raw array), byte-equal
94
+ // to the textarea storage contract.
95
+ expect(typeof emitted).toBe('string');
96
+ expect(JSON.parse(emitted)).toEqual(['studio.access', 'manage_users']);
97
+ });
98
+
99
+ it('removes a capability (and keeps emitting a JSON string) when toggled off', async () => {
100
+ const onChange = vi.fn();
101
+ render(
102
+ <CapabilityMultiSelectField
103
+ value={'["studio.access","manage_users"]'}
104
+ onChange={onChange}
105
+ field={{ name: 'system_permissions' } as any}
106
+ dataSource={mockDataSource()}
107
+ />,
108
+ );
109
+ fireEvent.click(await screen.findByRole('button', { name: 'Studio Access' }));
110
+ const emitted = onChange.mock.calls[0][0];
111
+ expect(typeof emitted).toBe('string');
112
+ expect(JSON.parse(emitted)).toEqual(['manage_users']);
113
+ });
114
+
115
+ it('preserves an unknown/legacy selected name not in the registry', async () => {
116
+ render(
117
+ <CapabilityMultiSelectField
118
+ value={'["studio.access","legacy.custom"]'}
119
+ onChange={vi.fn()}
120
+ field={{ name: 'system_permissions' } as any}
121
+ dataSource={mockDataSource()}
122
+ />,
123
+ );
124
+ // The unknown name still renders as a (selected) chip so it is not dropped.
125
+ const legacy = await screen.findByRole('button', { name: 'legacy.custom' });
126
+ expect(legacy).toHaveAttribute('aria-pressed', 'true');
127
+ });
128
+
129
+ it('renders selected labels as read-only badges when readonly', async () => {
130
+ render(
131
+ <CapabilityMultiSelectField
132
+ value={'["studio.access"]'}
133
+ onChange={vi.fn()}
134
+ field={{ name: 'system_permissions' } as any}
135
+ dataSource={mockDataSource()}
136
+ readonly
137
+ />,
138
+ );
139
+ // No toggle buttons in readonly mode.
140
+ expect(await screen.findByText('Studio Access')).toBeInTheDocument();
141
+ expect(screen.queryByRole('button', { name: 'Manage Users' })).not.toBeInTheDocument();
142
+ });
143
+ });
@@ -0,0 +1,204 @@
1
+ import React from 'react';
2
+ import { Badge, EmptyValue, cn } from '@object-ui/components';
3
+ import { SchemaRendererContext } from '@object-ui/react';
4
+ import type { DataSource, QueryParams } from '@object-ui/types';
5
+ import { FieldWidgetProps } from './types';
6
+
7
+ /**
8
+ * CapabilityMultiSelectField — structured picker for a permission set's
9
+ * `system_permissions` (ADR-0056 / epic #2398, phase P2).
10
+ *
11
+ * `sys_permission_set.system_permissions` is framework-declared as a
12
+ * `Field.textarea` that stores a **JSON-serialized array of capability names**
13
+ * (e.g. `["setup.access","studio.access"]`; `security-plugin.ts` writes
14
+ * `JSON.stringify(...)` and reads `parseJson(...)`). Editing that as raw JSON is
15
+ * the anti-pattern ADR-0056 removes. This widget renders the same value as a
16
+ * multi-select over the live `sys_capability` registry — grouped by scope,
17
+ * labelled, with the capability description on hover — while round-tripping the
18
+ * stored value **byte-for-byte** as a JSON string of names.
19
+ *
20
+ * Reached via the field `widget: 'capability-multiselect'` hint (stamped onto
21
+ * the object metadata in MetadataProvider), registered in the field registry as
22
+ * `field:capability-multiselect`.
23
+ */
24
+
25
+ interface Capability {
26
+ name: string;
27
+ label?: string;
28
+ description?: string;
29
+ scope?: string;
30
+ active?: boolean;
31
+ }
32
+
33
+ /**
34
+ * Parse the stored value into the selected capability-name list. The canonical
35
+ * storage is a JSON-string array; we also tolerate an already-parsed array (in
36
+ * case a caller hands us the parsed value) and a bare comma-separated string
37
+ * (legacy/hand-authored) so no selection is silently dropped.
38
+ */
39
+ export function parseCapabilityNames(value: unknown): string[] {
40
+ if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
41
+ if (typeof value === 'string') {
42
+ const s = value.trim();
43
+ if (!s) return [];
44
+ try {
45
+ const parsed = JSON.parse(s);
46
+ if (Array.isArray(parsed)) return parsed.map((v) => String(v)).filter(Boolean);
47
+ // A JSON scalar (unexpected) — treat as a single name.
48
+ return [String(parsed)].filter(Boolean);
49
+ } catch {
50
+ // Not JSON — fall back to comma-splitting so legacy values survive.
51
+ return s.split(',').map((x) => x.trim()).filter(Boolean);
52
+ }
53
+ }
54
+ return [];
55
+ }
56
+
57
+ /** Scope → group header. Order is intentional (platform powers first). */
58
+ const SCOPE_ORDER = ['platform', 'org', 'other'] as const;
59
+ const SCOPE_LABEL: Record<string, string> = {
60
+ platform: 'Platform',
61
+ org: 'Organization',
62
+ other: 'Other',
63
+ };
64
+
65
+ export function CapabilityMultiSelectField({
66
+ value,
67
+ onChange,
68
+ readonly,
69
+ className,
70
+ ...props
71
+ }: FieldWidgetProps<string | string[]>) {
72
+ const ctx = React.useContext(SchemaRendererContext);
73
+ const dataSource: DataSource | null =
74
+ (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
75
+ const disabled = (props as any).disabled as boolean | undefined;
76
+
77
+ const [caps, setCaps] = React.useState<Capability[] | null>(null);
78
+
79
+ const selected = React.useMemo(() => parseCapabilityNames(value), [value]);
80
+
81
+ // Load the live capability registry (active only). Mirrors LookupField's
82
+ // context-dataSource access — createFieldRenderer does not forward the
83
+ // dataSource prop, so the SchemaRenderer context is the reliable source.
84
+ React.useEffect(() => {
85
+ if (!dataSource || typeof (dataSource as any).find !== 'function') return;
86
+ let cancelled = false;
87
+ (async () => {
88
+ try {
89
+ const res = await dataSource.find('sys_capability', {
90
+ $filter: { active: true },
91
+ $top: 500,
92
+ } as QueryParams);
93
+ const rows: Capability[] =
94
+ (res as any)?.data ?? (res as any)?.records ?? (Array.isArray(res) ? res : []);
95
+ if (!cancelled) setCaps(Array.isArray(rows) ? rows : []);
96
+ } catch {
97
+ if (!cancelled) setCaps([]);
98
+ }
99
+ })();
100
+ return () => {
101
+ cancelled = true;
102
+ };
103
+ }, [dataSource]);
104
+
105
+ // Emit as a JSON string so the value round-trips byte-equivalent to the
106
+ // `system_permissions` textarea's `JSON.stringify(string[])` storage.
107
+ const emit = React.useCallback(
108
+ (names: string[]) => onChange(JSON.stringify(names) as any),
109
+ [onChange],
110
+ );
111
+
112
+ // Union of the fetched registry and any *selected* names not present in it
113
+ // (unknown / legacy / package-owned-inactive), so no current grant is hidden
114
+ // or dropped. Unknown names land in the "other" group.
115
+ const byName = React.useMemo(() => {
116
+ const map = new Map<string, Capability>();
117
+ for (const c of caps ?? []) if (c?.name) map.set(c.name, c);
118
+ for (const n of selected) if (!map.has(n)) map.set(n, { name: n, label: n, scope: 'other' });
119
+ return map;
120
+ }, [caps, selected]);
121
+
122
+ const labelFor = (name: string) => byName.get(name)?.label || name;
123
+
124
+ // Group options by scope for the editable grid. Computed BEFORE the readonly
125
+ // early-return so the hook order stays stable regardless of `readonly`.
126
+ const groups = React.useMemo(() => {
127
+ const buckets = new Map<string, Capability[]>();
128
+ for (const c of byName.values()) {
129
+ const key = (SCOPE_ORDER as readonly string[]).includes(c.scope as string)
130
+ ? (c.scope as string)
131
+ : 'other';
132
+ const list = buckets.get(key) ?? [];
133
+ list.push(c);
134
+ buckets.set(key, list);
135
+ }
136
+ return SCOPE_ORDER.filter((s) => buckets.has(s)).map((s) => ({
137
+ scope: s,
138
+ label: SCOPE_LABEL[s] ?? s,
139
+ items: (buckets.get(s) ?? []).sort((a, b) =>
140
+ (a.label || a.name).localeCompare(b.label || b.name),
141
+ ),
142
+ }));
143
+ }, [byName]);
144
+
145
+ if (readonly) {
146
+ if (selected.length === 0) return <EmptyValue />;
147
+ return (
148
+ <div className="flex flex-wrap gap-1">
149
+ {selected.map((name) => (
150
+ <Badge key={name} variant="outline" title={byName.get(name)?.description}>
151
+ {labelFor(name)}
152
+ </Badge>
153
+ ))}
154
+ </div>
155
+ );
156
+ }
157
+
158
+ const toggle = (name: string) => {
159
+ const next = selected.includes(name)
160
+ ? selected.filter((x) => x !== name)
161
+ : [...selected, name];
162
+ emit(next);
163
+ };
164
+
165
+ return (
166
+ <div className={cn('flex flex-col gap-3', className)}>
167
+ {caps === null && groups.length === 0 && (
168
+ <span className="text-sm text-muted-foreground">Loading capabilities…</span>
169
+ )}
170
+ {groups.map((group) => (
171
+ <div key={group.scope} className="flex flex-col gap-1.5">
172
+ <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
173
+ {group.label}
174
+ </div>
175
+ <div className="flex flex-wrap gap-1.5">
176
+ {group.items.map((cap) => {
177
+ const active = selected.includes(cap.name);
178
+ return (
179
+ <button
180
+ type="button"
181
+ key={cap.name}
182
+ onClick={() => toggle(cap.name)}
183
+ disabled={disabled}
184
+ aria-pressed={active}
185
+ title={cap.description || undefined}
186
+ className={cn(
187
+ 'rounded-full border px-3 py-1 text-sm transition-colors disabled:opacity-50',
188
+ active
189
+ ? 'border-primary bg-primary text-primary-foreground'
190
+ : 'border-input bg-background text-foreground hover:bg-accent',
191
+ )}
192
+ >
193
+ {cap.label || cap.name}
194
+ </button>
195
+ );
196
+ })}
197
+ </div>
198
+ </div>
199
+ ))}
200
+ </div>
201
+ );
202
+ }
203
+
204
+ export default CapabilityMultiSelectField;
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetProps } from './types';
4
+ import { openNativePicker } from './openNativePicker';
4
5
 
5
6
  /**
6
7
  * DateField - Date picker input widget
@@ -20,6 +21,10 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
20
21
  type="date"
21
22
  value={value || ''}
22
23
  onChange={(e) => onChange(e.target.value)}
24
+ onClick={(e) => {
25
+ openNativePicker(e.currentTarget);
26
+ domProps.onClick?.(e);
27
+ }}
23
28
  disabled={readonly || domProps.disabled}
24
29
  />
25
30
  );
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetProps } from './types';
4
+ import { openNativePicker } from './openNativePicker';
4
5
 
5
6
  /**
6
7
  * DateTimeField - Combined date and time picker widget
@@ -26,6 +27,10 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
26
27
  type="datetime-local"
27
28
  value={value || ''}
28
29
  onChange={(e) => onChange(e.target.value)}
30
+ onClick={(e) => {
31
+ openNativePicker(e.currentTarget);
32
+ domProps.onClick?.(e);
33
+ }}
29
34
  disabled={readonly || domProps.disabled}
30
35
  />
31
36
  );