+
diff --git a/admin/src/components/form/formState.ts b/admin/src/components/form/formState.ts
index 2b6c07e..c92fc1b 100644
--- a/admin/src/components/form/formState.ts
+++ b/admin/src/components/form/formState.ts
@@ -5,6 +5,30 @@ import { isRegistered } from './registry'
export type FormMode = 'create' | 'update'
+/** One form tab: the YAML tab label, or the default tab for untabbed fields. */
+export interface TabItem {
+ key: string
+ label: string
+ errors: number
+}
+
+/** Key of the tab that holds fields without a `tab`. */
+export const DEFAULT_TAB = 'default'
+
+/** Tab key of a field: its tab label, or the default tab. */
+export function tabOf(field: FormField): string {
+ return field.tab ? `tab:${field.tab}` : DEFAULT_TAB
+}
+
+/** DOM ids of the tab button and its panel, by tab position. */
+export function tabDomId(prefix: string, index: number): string {
+ return `${prefix}-tab-${index}`
+}
+
+export function panelDomId(prefix: string, index: number): string {
+ return `${prefix}-panel-${index}`
+}
+
/** Winter `context:` semantics: no context shows the field everywhere. */
export function contextAllows(field: FormField, mode: FormMode): boolean {
const context = field.context
diff --git a/admin/src/components/form/registry.ts b/admin/src/components/form/registry.ts
index 9413198..3513ece 100644
--- a/admin/src/components/form/registry.ts
+++ b/admin/src/components/form/registry.ts
@@ -1,11 +1,15 @@
// Field renderer registry (D-05). A form field's `type` selects the control
// component; any type without a renderer gets UnsupportedField, the design's
-// dashed box, so an unknown type never breaks the form. Phase 10.1 turns this
-// seam into the plugin extension point.
+// dashed box, so an unknown type never breaks the form. The relation manager
+// arrives with its own component in a later plan. Phase 10.1 turns this seam
+// into the plugin extension point.
import type { Component } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
+import CheckboxField from './fields/CheckboxField.vue'
import DropdownField from './fields/DropdownField.vue'
import NumberField from './fields/NumberField.vue'
+import RelationField from './fields/RelationField.vue'
+import SwitchField from './fields/SwitchField.vue'
import TextField from './fields/TextField.vue'
import TextareaField from './fields/TextareaField.vue'
import UnsupportedField from './fields/UnsupportedField.vue'
@@ -30,10 +34,13 @@ const renderers = new Map([
['textarea', TextareaField],
['number', NumberField],
['dropdown', DropdownField],
+ ['switch', SwitchField],
+ ['checkbox', CheckboxField],
+ ['relation', RelationField],
])
/** Types whose control shows the label itself (toggle cards). */
-const selfLabelled = new Set()
+const selfLabelled = new Set(['switch', 'checkbox'])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField
@@ -82,3 +89,16 @@ export function controlClass(invalid: boolean | undefined): string {
invalid ? 'border-danger' : 'border-border-strong',
].join(' ')
}
+
+/** Truthiness of a stored toggle value (true, 1, "1", "true"). */
+export function toggleOn(value: unknown): boolean {
+ if (typeof value === 'string') {
+ return value !== '' && value !== '0' && value.toLowerCase() !== 'false'
+ }
+ return Boolean(value)
+}
+
+/** A toggle's next value in the stored value's own type (1/0 or boolean). */
+export function toggleValue(current: unknown, on: boolean): boolean | number {
+ return typeof current === 'number' ? (on ? 1 : 0) : on
+}
diff --git a/admin/src/components/shell/AppShell.vue b/admin/src/components/shell/AppShell.vue
index 8439e57..15aa24f 100644
--- a/admin/src/components/shell/AppShell.vue
+++ b/admin/src/components/shell/AppShell.vue
@@ -45,7 +45,10 @@ const initials = computed(() =>