@kyro-cms/admin 0.8.0 → 0.9.1

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 (100) hide show
  1. package/dist/index.cjs +11960 -11006
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +67 -65
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.cts +563 -0
  6. package/dist/index.d.ts +7 -7
  7. package/dist/index.js +12183 -11238
  8. package/dist/index.js.map +1 -1
  9. package/package.json +15 -11
  10. package/src/components/ActionBar.tsx +27 -14
  11. package/src/components/Admin.tsx +1 -1
  12. package/src/components/ApiKeysManager.tsx +5 -5
  13. package/src/components/AutoForm.tsx +585 -369
  14. package/src/components/BrandingHub.tsx +7 -4
  15. package/src/components/CreateView.tsx +2 -0
  16. package/src/components/DetailView.tsx +71 -56
  17. package/src/components/DeveloperCenter.tsx +8 -6
  18. package/src/components/FieldRenderer.tsx +94 -19
  19. package/src/components/ListView.tsx +33 -20
  20. package/src/components/MediaGallery.tsx +219 -194
  21. package/src/components/PluginsManager.tsx +197 -70
  22. package/src/components/RestPlayground.tsx +7 -7
  23. package/src/components/SessionsManager.tsx +1 -1
  24. package/src/components/SettingsPage.tsx +22 -0
  25. package/src/components/Sidebar.astro +13 -41
  26. package/src/components/UserManagement.tsx +153 -15
  27. package/src/components/UserMenu.tsx +30 -4
  28. package/src/components/VersionHistoryPanel.tsx +112 -119
  29. package/src/components/WebhookManager.tsx +6 -4
  30. package/src/components/blocks/ArrayBlock.tsx +6 -23
  31. package/src/components/blocks/BlockEditModal.tsx +82 -309
  32. package/src/components/blocks/CardBlock.tsx +35 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +57 -31
  34. package/src/components/blocks/GenericBlock.tsx +44 -0
  35. package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
  36. package/src/components/blocks/HeroBlock.tsx +5 -14
  37. package/src/components/blocks/RichTextBlock.tsx +5 -5
  38. package/src/components/blocks/index.ts +5 -3
  39. package/src/components/fields/AccordionField.tsx +2 -2
  40. package/src/components/fields/ArrayField.tsx +1 -1
  41. package/src/components/fields/ArrayLayout.tsx +120 -29
  42. package/src/components/fields/BlocksField.tsx +430 -50
  43. package/src/components/fields/CardField.tsx +73 -0
  44. package/src/components/fields/CheckboxField.tsx +7 -3
  45. package/src/components/fields/DateField.tsx +4 -1
  46. package/src/components/fields/GroupLayout.tsx +2 -2
  47. package/src/components/fields/HeadingSubheadingField.tsx +43 -0
  48. package/src/components/fields/ListField.tsx +2 -2
  49. package/src/components/fields/NumberField.tsx +4 -1
  50. package/src/components/fields/RelationshipField.tsx +153 -87
  51. package/src/components/fields/RichTextField.tsx +781 -0
  52. package/src/components/fields/SecretField.tsx +102 -0
  53. package/src/components/fields/SelectField.tsx +19 -6
  54. package/src/components/fields/TabsLayout.tsx +19 -9
  55. package/src/components/fields/TextField.tsx +4 -1
  56. package/src/components/fields/UploadField.tsx +122 -56
  57. package/src/components/fields/extensions/blockComponents.tsx +103 -174
  58. package/src/components/fields/extensions/blocksStore.ts +8 -1
  59. package/src/components/fields/index.ts +4 -2
  60. package/src/components/ui/PageHeader.tsx +5 -5
  61. package/src/components/ui/SlidePanel.tsx +8 -3
  62. package/src/components/ui/icons.tsx +109 -109
  63. package/src/components/users/UserDetail.tsx +79 -16
  64. package/src/hooks/useAutoFormState.ts +125 -62
  65. package/src/integration.ts +148 -46
  66. package/src/kyro-cms.d.ts +7 -2
  67. package/src/layouts/AuthLayout.astro +14 -2
  68. package/src/lib/autoform-store.ts +85 -52
  69. package/src/lib/change-source.ts +9 -0
  70. package/src/lib/config.ts +104 -8
  71. package/src/lib/globals.ts +44 -9
  72. package/src/lib/normalize-upload-fields.ts +41 -0
  73. package/src/lib/paths.ts +2 -2
  74. package/src/lib/resolve-field-value.ts +110 -0
  75. package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
  76. package/src/lib/shim/use-sync-external-store.js +1 -0
  77. package/src/lib/stores/index.ts +1 -0
  78. package/src/lib/useResourceManager.ts +4 -4
  79. package/src/lib/vite-shim-plugin.ts +100 -0
  80. package/src/pages/[collection]/[id].astro +1 -1
  81. package/src/pages/preview/[collection]/[id].astro +4 -4
  82. package/src/pages/settings/[slug].astro +2 -2
  83. package/src/styles/main.css +60 -54
  84. package/README.md +0 -46
  85. package/dist/EditorClient-Q23UXR37.cjs +0 -468
  86. package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
  87. package/dist/EditorClient-T5PASFNR.js +0 -466
  88. package/dist/EditorClient-T5PASFNR.js.map +0 -1
  89. package/dist/chunk-3BGDYKTD.cjs +0 -348
  90. package/dist/chunk-3BGDYKTD.cjs.map +0 -1
  91. package/dist/chunk-EEFXLQVT.js +0 -3
  92. package/dist/chunk-EEFXLQVT.js.map +0 -1
  93. package/src/components/blocks/ButtonBlock.tsx +0 -64
  94. package/src/components/blocks/ColumnsBlock.tsx +0 -55
  95. package/src/components/blocks/DividerBlock.tsx +0 -43
  96. package/src/components/blocks/LinkBlock.tsx +0 -65
  97. package/src/components/blocks/VStackBlock.tsx +0 -29
  98. package/src/components/fields/EditorClient.tsx +0 -535
  99. package/src/components/fields/PortableTextField.tsx +0 -155
  100. package/src/components/fields/PortableTextRenderer.tsx +0 -68
@@ -11,12 +11,16 @@ interface CheckboxFieldComponentProps {
11
11
 
12
12
  export default function CheckboxField({
13
13
  field,
14
- value = false,
14
+ value,
15
15
  onChange,
16
16
  error,
17
17
  disabled,
18
18
  }: CheckboxFieldComponentProps) {
19
- const isReadOnly = field.admin?.readOnly;
19
+ const isReadOnly =
20
+ typeof field.admin?.readOnly === "function"
21
+ ? false
22
+ : Boolean(field.admin?.readOnly);
23
+ const checked = value ?? false;
20
24
 
21
25
  return (
22
26
  <FieldLayout
@@ -27,7 +31,7 @@ export default function CheckboxField({
27
31
  <label className="flex items-center gap-2.5 cursor-pointer group py-0.5">
28
32
  <input
29
33
  type="checkbox"
30
- checked={value}
34
+ checked={checked}
31
35
  onChange={(e) => onChange?.(e.target.checked)}
32
36
  disabled={disabled || isReadOnly}
33
37
  className={`w-4 h-4 rounded border-[var(--kyro-border)] text-[var(--kyro-primary)] focus:ring-[var(--kyro-primary)] transition-all ${
@@ -16,7 +16,10 @@ export default function DateField({
16
16
  error,
17
17
  disabled,
18
18
  }: DateFieldComponentProps) {
19
- const isReadOnly = field.admin?.readOnly;
19
+ const isReadOnly =
20
+ typeof field.admin?.readOnly === "function"
21
+ ? false
22
+ : Boolean(field.admin?.readOnly);
20
23
 
21
24
  return (
22
25
  <FieldLayout
@@ -22,10 +22,10 @@ export function GroupLayout({
22
22
 
23
23
  return (
24
24
  <div className="kyro-form-group border border-[var(--kyro-border)] rounded-[var(--kyro-radius-lg)] p-6 bg-[var(--kyro-surface-accent)]/30">
25
- <h3 className="text-sm font-bold tracking-widest text-[var(--kyro-text-primary)] mb-6 border-b border-[var(--kyro-border)] pb-2 inline-block">
25
+ <h3 className="text-sm font-bold tracking-widest text-[var(--kyro-text-primary)] mb-6 border-b border-[var(--kyro-border)] pb-2 inline-block">
26
26
  {field.label || field.name}
27
27
  </h3>
28
- <div className="space-y-6">
28
+ <div className={field.admin?.inline ? "flex items-start gap-4" : "space-y-6"}>
29
29
  {(field as Field & { fields?: Field[] }).fields.map((f: Field) =>
30
30
  renderField(f, groupData, onChange),
31
31
  )}
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+
3
+ interface HeadingSubheadingFieldProps {
4
+ heading?: string;
5
+ subheading?: string;
6
+ onChange: (field: string, value: string) => void;
7
+ compact?: boolean;
8
+ }
9
+
10
+ export const HeadingSubheadingField: React.FC<HeadingSubheadingFieldProps> = ({
11
+ heading = "",
12
+ subheading = "",
13
+ onChange,
14
+ compact = false,
15
+ }) => {
16
+ const inputClass = compact
17
+ ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
18
+ : "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
19
+
20
+ const textareaClass = compact
21
+ ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[50px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
22
+ : "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[80px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
23
+
24
+ return (
25
+ <div className={compact ? "space-y-2" : "space-y-3"}>
26
+ <input
27
+ type="text"
28
+ value={heading}
29
+ onChange={(e) => onChange("heading", e.target.value)}
30
+ className={`${inputClass} font-bold text-base`}
31
+ placeholder="Heading..."
32
+ />
33
+ <textarea
34
+ value={subheading}
35
+ onChange={(e) => onChange("subheading", e.target.value)}
36
+ className={textareaClass}
37
+ placeholder="Subheading..."
38
+ />
39
+ </div>
40
+ );
41
+ };
42
+
43
+ export default HeadingSubheadingField;
@@ -32,13 +32,13 @@ export const ListField: React.FC<ListFieldProps> = ({
32
32
  };
33
33
 
34
34
  const inputClass = compact
35
- ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
35
+ ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded-md bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
36
36
  : "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
37
37
 
38
38
  return (
39
39
  <div className={compact ? "space-y-1.5" : "space-y-2"}>
40
40
  {items.length === 0 ? (
41
- <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-lg">
41
+ <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-md">
42
42
  No items. Type below to add.
43
43
  </div>
44
44
  ) : (
@@ -16,7 +16,10 @@ export default function NumberField({
16
16
  error,
17
17
  disabled,
18
18
  }: NumberFieldComponentProps) {
19
- const isReadOnly = field.admin?.readOnly;
19
+ const isReadOnly =
20
+ typeof field.admin?.readOnly === "function"
21
+ ? false
22
+ : Boolean(field.admin?.readOnly);
20
23
 
21
24
  return (
22
25
  <FieldLayout
@@ -1,4 +1,4 @@
1
- import { useEffect, useState, useRef } from "react";
1
+ import { useEffect, useState, useRef, useCallback } from "react";
2
2
  import { Search, X, ChevronDown, Loader2 } from "../ui/icons";
3
3
  import { apiGet, buildSearchQuery } from "../../lib/api";
4
4
 
@@ -15,12 +15,33 @@ interface RelationshipFieldProps {
15
15
  placeholder?: string;
16
16
  };
17
17
  };
18
- value?: string | string[] | null;
19
- onChange?: (value: string | string[] | null) => void;
18
+ value?: unknown;
19
+ onChange?: (value: unknown) => void;
20
20
  error?: string;
21
21
  disabled?: boolean;
22
22
  }
23
23
 
24
+ interface ResolvedDoc {
25
+ id: string;
26
+ relationTo?: string;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ function getLabel(opt: Record<string, unknown>): string {
31
+ const mainTabs = opt?.mainTabs as Record<string, unknown> | undefined;
32
+
33
+ return (
34
+ (opt?.title as string) ||
35
+ (mainTabs?.title as string) ||
36
+ (opt?.name as string) ||
37
+ (opt?.label as string) ||
38
+ (opt?.email as string) ||
39
+ (opt?.filename as string) ||
40
+ (opt?.slug as string) ||
41
+ "Untitled"
42
+ );
43
+ }
44
+
24
45
  export function RelationshipField({
25
46
  field,
26
47
  value,
@@ -30,28 +51,85 @@ export function RelationshipField({
30
51
  }: RelationshipFieldProps) {
31
52
  const [isOpen, setIsOpen] = useState(false);
32
53
  const [search, setSearch] = useState("");
33
- const [options, setOptions] = useState<Record<string, unknown>[]>([]);
54
+ const [options, setOptions] = useState<ResolvedDoc[]>([]);
34
55
  const [loading, setLoading] = useState(false);
56
+ const [selectedDocs, setSelectedDocs] = useState<ResolvedDoc[]>([]);
57
+ const fetchedIdsRef = useRef<Set<string>>(new Set());
35
58
  const containerRef = useRef<HTMLDivElement>(null);
59
+ const onChangeRef = useRef<(value: unknown) => void>(() => {});
60
+ onChangeRef.current = onChange || (() => {});
36
61
 
37
62
  const isMultiple = field.hasMany;
38
63
  const relationTo = Array.isArray(field.relationTo)
39
64
  ? field.relationTo
40
65
  : [field.relationTo];
41
- const targetCollection = relationTo[0];
66
+ const isPolymorphic = relationTo.length > 1;
67
+ const [activeRelation, setActiveRelation] = useState(relationTo[0]);
68
+
69
+ const extractIds = useCallback((): string[] => {
70
+ if (!value) return [];
71
+ const items = isMultiple
72
+ ? Array.isArray(value) ? value : []
73
+ : value ? [value] : [];
74
+ return items.map((item) => {
75
+ if (typeof item === "object" && item !== null) {
76
+ return (item as { value?: string }).value || (item as { id?: string }).id || "";
77
+ }
78
+ return String(item);
79
+ }).filter(Boolean);
80
+ }, [value, isMultiple]);
42
81
 
43
- const fetchOptions = (query: string = "") => {
82
+ const fetchSelectedDocs = useCallback((ids: string[]) => {
83
+ if (ids.length === 0) return;
84
+ ids.forEach((id) => {
85
+ if (fetchedIdsRef.current.has(id)) return;
86
+ fetchedIdsRef.current.add(id);
87
+ const rel = isPolymorphic
88
+ ? (() => {
89
+ if (!value) return activeRelation;
90
+ const items = isMultiple
91
+ ? Array.isArray(value) ? value : []
92
+ : [value];
93
+ const match = items.find((item) => {
94
+ if (typeof item === "object" && item !== null) {
95
+ return (item as { value?: string }).value === id || (item as { id?: string }).id === id;
96
+ }
97
+ return false;
98
+ });
99
+ return match && typeof match === "object" ? (match as { relationTo?: string }).relationTo || activeRelation : activeRelation;
100
+ })()
101
+ : activeRelation;
102
+ apiGet<Record<string, unknown>>(`/api/${rel}/${id}`)
103
+ .then((response) => {
104
+ const doc = (response as any).data || response;
105
+ if (!doc || typeof doc !== "object") return;
106
+
107
+ setSelectedDocs((prev) => {
108
+ if (prev.some((d) => d.id === id)) return prev;
109
+ return [...prev, { ...doc, id: String((doc as any).id), relationTo: rel }];
110
+ });
111
+ })
112
+ .catch(() => {});
113
+ });
114
+ }, [isPolymorphic, value, activeRelation, isMultiple]);
115
+
116
+ useEffect(() => {
117
+ const ids = extractIds();
118
+ fetchSelectedDocs(ids);
119
+ }, [extractIds, fetchSelectedDocs]);
120
+
121
+ const fetchOptions = useCallback((query: string = "") => {
44
122
  setLoading(true);
45
123
  const searchFields = ["title", "name", "label", "email"];
46
- const url = `/api/${targetCollection}?${buildSearchQuery(query, searchFields)}`;
124
+ const url = `/api/${activeRelation}?${buildSearchQuery(query, searchFields)}`;
47
125
 
48
- apiGet(url)
126
+ apiGet<{ docs?: Record<string, unknown>[] }>(url)
49
127
  .then((data) => {
50
128
  setOptions((prev) => {
51
129
  const existingIds = new Set(prev.map((o) => o.id));
52
- const newDocs = (data.docs || []).filter(
53
- (d: Record<string, unknown>) => !existingIds.has(d.id as string),
54
- );
130
+ const newDocs: ResolvedDoc[] = (data.docs || []).filter(
131
+ (d) => !existingIds.has(d.id as string),
132
+ ).map((d) => ({ ...d, id: d.id as string }));
55
133
  return [...prev, ...newDocs];
56
134
  });
57
135
  setLoading(false);
@@ -59,39 +137,14 @@ const newDocs = (data.docs || []).filter(
59
137
  .catch(() => {
60
138
  setLoading(false);
61
139
  });
62
- };
63
-
64
- const fetchSelectedItems = () => {
65
- const items: (string | Record<string, unknown>)[] = isMultiple
66
- ? Array.isArray(value)
67
- ? value
68
- : []
69
- : value
70
- ? [value]
71
- : [];
72
- items.forEach((itemId) => {
73
- const id = typeof itemId === "object" ? itemId?.id : itemId;
74
- if (id && !options.some((o) => o.id === id)) {
75
- apiGet(`/api/${targetCollection}/${id}`)
76
- .then((doc) => {
77
- setOptions((prev) => [...prev, doc]);
78
- })
79
- .catch(() => {});
80
- }
81
- });
82
- };
83
-
84
- useEffect(() => {
85
- if (value) {
86
- fetchSelectedItems();
87
- }
88
- }, [value, targetCollection]);
140
+ }, [activeRelation]);
89
141
 
90
142
  useEffect(() => {
91
143
  if (isOpen) {
144
+ setOptions([]);
92
145
  fetchOptions(search);
93
146
  }
94
- }, [isOpen, targetCollection]);
147
+ }, [isOpen, activeRelation]);
95
148
 
96
149
  useEffect(() => {
97
150
  const handleClickOutside = (event: MouseEvent) => {
@@ -106,24 +159,14 @@ const fetchSelectedItems = () => {
106
159
  return () => document.removeEventListener("mousedown", handleClickOutside);
107
160
  }, []);
108
161
 
109
- const getLabel = (opt: Record<string, unknown>) => {
110
- return (
111
- opt?.title ||
112
- opt?.name ||
113
- opt?.label ||
114
- opt?.email ||
115
- opt?.filename ||
116
- opt?.slug ||
117
- String(opt?.id) ||
118
- "Untitled"
119
- );
120
- };
121
-
122
- const getValueId = (val: string | Record<string, unknown>) => {
123
- return val?.id || val;
162
+ const getValueId = (val: unknown): string => {
163
+ if (typeof val === "object" && val !== null) {
164
+ return (val as { value?: string }).value || (val as { id?: string }).id || "";
165
+ }
166
+ return String(val);
124
167
  };
125
168
 
126
- const isSelected = (opt: Record<string, unknown>) => {
169
+ const isSelected = (opt: Record<string, unknown>): boolean => {
127
170
  const optId = opt.id;
128
171
  if (!value) return false;
129
172
  if (isMultiple && Array.isArray(value)) {
@@ -135,60 +178,56 @@ const fetchSelectedItems = () => {
135
178
  const handleSelect = (opt: Record<string, unknown>) => {
136
179
  const optId = opt.id;
137
180
  if (isMultiple) {
138
- const current = Array.isArray(value) ? value : [];
181
+ const current: unknown[] = Array.isArray(value) ? value : [];
139
182
  if (isSelected(opt)) {
140
- onChange?.(current.filter((v) => getValueId(v) !== optId));
183
+ onChangeRef.current?.(current.filter((v) => getValueId(v) !== optId));
141
184
  } else {
142
- onChange?.([...current, optId]);
185
+ const newItem = isPolymorphic
186
+ ? { relationTo: activeRelation, value: optId }
187
+ : optId;
188
+ onChangeRef.current?.([...current, newItem]);
143
189
  }
144
190
  } else {
145
191
  if (isSelected(opt)) {
146
- onChange?.(null);
192
+ onChangeRef.current?.(null);
147
193
  } else {
148
- onChange?.(optId);
194
+ const newItem = isPolymorphic
195
+ ? { relationTo: activeRelation, value: optId }
196
+ : optId;
197
+ onChangeRef.current?.(newItem);
149
198
  setIsOpen(false);
150
199
  setSearch("");
151
200
  }
152
201
  }
153
202
  };
154
203
 
155
- const handleClear = () => {
156
- onChange?.(isMultiple ? [] : null);
157
- };
158
-
159
204
  const renderSelectedItems = () => {
160
205
  if (!value) return null;
161
206
 
162
- let items: (string | Record<string, unknown>)[];
163
- if (isMultiple) {
164
- items = Array.isArray(value) ? value : [];
165
- } else {
166
- items = value ? [value] : [];
167
- }
207
+ const items: unknown[] = isMultiple
208
+ ? Array.isArray(value) ? value : []
209
+ : value ? [value] : [];
168
210
 
169
211
  return (
170
212
  <div className="flex flex-wrap gap-1.5 mt-2">
171
- {items.map((item, idx) => {
172
- const rawId = typeof item === "object" ? item?.id || item : item;
173
- const opt = options.find((o) => o.id === rawId);
174
- const label = opt ? getLabel(opt) : String(rawId).slice(0, 8);
213
+ {items.map((item) => {
214
+ const rawId = getValueId(item);
215
+ const doc = selectedDocs.find((d) => d.id === rawId);
216
+ const label = doc ? getLabel(doc) : rawId.slice(0, 12);
217
+ const rel = isPolymorphic && doc ? doc.relationTo : null;
175
218
  return (
176
219
  <span
177
- key={idx}
220
+ key={rawId}
178
221
  className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-[var(--kyro-sidebar-active)]/10 text-[var(--kyro-sidebar-active)]"
179
222
  >
223
+ {rel && <span className="opacity-60 mr-0.5">{rel}:</span>}
180
224
  {label}
181
225
  {!disabled && (
182
226
  <button
183
227
  type="button"
184
228
  onClick={() => {
185
- if (isMultiple) {
186
- onChange?.(
187
- (items as (string | Record<string, unknown>)[]).filter((_: unknown, i: number) => i !== idx),
188
- );
189
- } else {
190
- onChange?.(null);
191
- }
229
+ const filtered = items.filter((value) => getValueId(value) !== rawId);
230
+ onChangeRef.current?.(isMultiple ? filtered : (filtered[0] ?? null));
192
231
  }}
193
232
  className="hover:opacity-70"
194
233
  >
@@ -213,6 +252,28 @@ onChange?.(
213
252
  </label>
214
253
  )}
215
254
  <div ref={containerRef} className="relative">
255
+ {isPolymorphic && (
256
+ <div className="flex gap-1 mb-1.5">
257
+ {relationTo.map((rel) => (
258
+ <button
259
+ key={rel}
260
+ type="button"
261
+ onClick={() => {
262
+ setActiveRelation(rel);
263
+ setOptions([]);
264
+ setSearch("");
265
+ }}
266
+ className={`px-2 py-0.5 text-[10px] font-bold rounded transition-colors ${
267
+ activeRelation === rel
268
+ ? "kyro-btn-primary"
269
+ : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-border)]"
270
+ }`}
271
+ >
272
+ {rel}
273
+ </button>
274
+ ))}
275
+ </div>
276
+ )}
216
277
  <div className="relative">
217
278
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
218
279
  <input
@@ -225,10 +286,15 @@ onChange?.(
225
286
  }}
226
287
  onFocus={() => setIsOpen(true)}
227
288
  placeholder={
228
- field.admin?.placeholder || `Search ${targetCollection}...`
289
+ field.admin?.placeholder || `Search ${activeRelation}...`
290
+ }
291
+ disabled={
292
+ disabled ||
293
+ (typeof field.admin?.readOnly === "function"
294
+ ? false
295
+ : Boolean(field.admin?.readOnly))
229
296
  }
230
- disabled={disabled || field.admin?.readOnly}
231
- className="w-full pl-9 pr-10 py-2 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent disabled:opacity-50"
297
+ className="w-full pl-9 pr-10 py-2 border border-[var(--kyro-border)] rounded-md bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent disabled:opacity-50"
232
298
  />
233
299
  <div className="absolute right-3 top-1/2 -translate-y-1/2">
234
300
  {loading ? (
@@ -242,7 +308,7 @@ onChange?.(
242
308
  </div>
243
309
 
244
310
  {isOpen && (
245
- <div className="absolute z-20 w-full mt-1 border border-[var(--kyro-border)] rounded-lg shadow-lg bg-[var(--kyro-surface)] max-h-64 overflow-auto">
311
+ <div className="relative z-20 w-full mt-1 border border-[var(--kyro-border)] rounded-lg shadow-lg bg-[var(--kyro-surface)] max-h-64 overflow-auto">
246
312
  {loading ? (
247
313
  <div className="p-4 text-center text-sm text-[var(--kyro-text-muted)]">
248
314
  Loading...
@@ -272,7 +338,7 @@ onChange?.(
272
338
  </span>
273
339
  )}
274
340
  </div>
275
- {opt.slug && (
341
+ {"slug" in opt && typeof opt.slug === "string" && (
276
342
  <div className="text-xs text-[var(--kyro-text-muted)]">
277
343
  {opt.slug}
278
344
  </div>