Form layout
The layout is one CSS grid per level. colCount sets the track count, colSpan widens an item inside it, and groups may override the count for their own subtree.
Responsiveness is a container query, not a window-width callback: colCountByScreen keys off the form's own inline size, so a form inside a dialog, a drawer or a grid cell picks the right column count without any JavaScript resize listener.
Fixed columns
A numeric colCount produces repeat(n, minmax(0, 1fr)). An item's colSpan is clamped to the count in force, so a span of 4 in a 2-column form spans 2 rather than overflowing.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OgeFormItem } from '@oge-ui/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-form [(formData)]="record" [colCount]="columns()">
<oge-form-item field="code" label="Code" />
<oge-form-item field="name" label="Name" />
<oge-form-item field="owner" label="Owner" />
<oge-form-item field="summary" label="Summary" [colSpan]="columns()" />
</oge-form>
`,
})
export class Demo {
protected readonly columns = signal(3);
protected readonly record = signal({
code: 'OGE-1',
name: 'Form layout',
owner: 'Ada',
summary: '',
});
}Auto-fit columns
The default. repeat(auto-fit, minmax(minColWidth, 1fr)) fits as many columns as the form is wide, with no breakpoints to maintain. Resize the browser — or the card — and the count follows.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm } from '@oge-ui/forms';
import type { OgeFormItemData } from '@oge-ui/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- the default: as many 260px columns as the FORM is wide -->
<oge-form
[(formData)]="server"
[items]="fields"
colCount="auto"
[minColWidth]="260"
/>
`,
})
export class Demo {
protected readonly server = signal({
host: 'db.internal',
port: 5432,
user: 'postgres',
database: 'oge',
});
protected readonly fields: OgeFormItemData[] = [
{ field: 'host', label: 'Host' },
{ field: 'port', label: 'Port' },
{ field: 'user', label: 'User' },
{ field: 'database', label: 'Database' },
];
}Responsive by container
Explicit counts per breakpoint when auto-fit is not precise enough. The breakpoints are container queries on the form itself — xs under 480px, then 480 / 720 / 960 / 1200 — so the same form nested in a narrow panel behaves like a phone layout even on a wide screen.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm } from '@oge-ui/forms';
import type { OgeFormItemData } from '@oge-ui/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- keyed to the form's own width, not the window's, so this
works identically inside a dialog, a drawer or a grid cell -->
<oge-form
[(formData)]="server"
[items]="fields"
[colCountByScreen]="{ xs: 1, sm: 2, md: 3, lg: 4 }"
/>
`,
})
export class Demo {
protected readonly server = signal({
host: 'db.internal',
port: 5432,
user: 'postgres',
database: 'oge',
});
protected readonly fields: OgeFormItemData[] = [
{ field: 'host', label: 'Host' },
{ field: 'port', label: 'Port' },
{ field: 'user', label: 'User' },
{ field: 'database', label: 'Database' },
];
}Nested groups
Groups nest as nested fieldsets, each with its own column count. That is the structure assistive technology reads as “this block of fields belongs together”, and it is why the group is a real fieldset rather than a styled div.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OgeFormGroup, OgeFormItem } from '@oge-ui/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormGroup, OgeFormItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-form [(formData)]="company" [colCount]="2">
<oge-form-group caption="Company" [colCount]="2">
<oge-form-item field="name" label="Name" [colSpan]="2" />
<oge-form-item field="taxId" label="Tax id" />
<oge-form-item field="employees" label="Employees" />
<oge-form-group caption="Billing address" [colCount]="2">
<oge-form-item field="street" label="Street" [colSpan]="2" />
<oge-form-item field="city" label="City" />
<oge-form-item field="postalCode" label="Postal code" />
</oge-form-group>
</oge-form-group>
</oge-form>
`,
})
export class Demo {
protected readonly company = signal({
name: 'OGE UI',
taxId: '',
employees: 12,
street: '',
city: '',
postalCode: '',
});
}Tab sections
Wrap the groups in <oge-form-tabs> and each group becomes a tab, its caption the tab text. The strip, its keyboard handling and its overflow come from @oge-ui/tabs — none of it is re-implemented. A tab holding invalid fields gets a count badge, and a failed submit selects that tab before focusing the field.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OgeFormGroup, OgeFormItem, OgeFormTabs } from '@oge-ui/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormGroup, OgeFormItem, OgeFormTabs],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- one tab per group; the tab strip, its keyboard handling and its
overflow come from @oge-ui/tabs, not from a copy of it -->
<oge-form [(formData)]="employee" [showValidationSummary]="true">
<oge-form-tabs>
<oge-form-group caption="Personal" [colCount]="2">
<oge-form-item field="firstName" label="First name" />
<oge-form-item field="lastName" label="Last name" />
</oge-form-group>
<oge-form-group caption="Employment" [colCount]="2">
<oge-form-item field="title" label="Title" [isRequired]="true" />
<oge-form-item field="salary" label="Salary" />
</oge-form-group>
</oge-form-tabs>
</oge-form>
`,
})
export class Demo {
protected readonly employee = signal({
firstName: 'Ada',
lastName: 'Lovelace',
title: '',
salary: 120000,
});
}Accordion sections
The same idea over @oge-ui/layout. A panel holding an invalid field gets the accordion's own invalid indicator — the danger rail, the dot and its screen-reader label — and a failed submit expands it.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OgeFormAccordion, OgeFormGroup, OgeFormItem } from '@oge-ui/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormAccordion, OgeFormGroup, OgeFormItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-form [(formData)]="employee">
<oge-form-accordion [expandedKeys]="[]">
<oge-form-group caption="Personal">
<oge-form-item field="firstName" label="First name" />
</oge-form-group>
<oge-form-group caption="Employment">
<oge-form-item field="title" label="Title" [isRequired]="true" />
</oge-form-group>
</oge-form-accordion>
</oge-form>
`,
})
export class Demo {
protected readonly employee = signal({
firstName: 'Ada',
lastName: 'Lovelace',
title: '',
salary: 120000,
});
}Read-only & disabled
Form-level disabled wraps the fields in a <fieldset disabled>; readOnly forwards to every editor. Both fall through group level to item level, and an item may opt out. In [fieldTree] mode the schema owns this instead — express it with disabled() / readonly(), because the FormField directive writes those inputs itself.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OgeFormItem } from '@oge-ui/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-form
[(formData)]="invoice"
[readOnly]="locked()"
[disabled]="archived()"
[colCount]="2"
>
<oge-form-item field="number" label="Number" />
<oge-form-item field="total" label="Total" />
<!-- an item may always opt out of the form-level state -->
<oge-form-item field="comment" label="Comment" [readOnly]="false" />
</oge-form>
`,
})
export class Demo {
protected readonly locked = signal(true);
protected readonly archived = signal(false);
protected readonly invoice = signal({
number: 'INV-204',
total: 1290,
comment: '',
});
}Visibility & order
visible drops an item from the layout entirely — no hidden input, no stale value in the DOM. visibleIndex pulls items to the front in index order; everything without one keeps its declaration order behind them.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OgeFormItem } from '@oge-ui/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-form [(formData)]="shipment" [colCount]="2">
<oge-form-item field="carrier" label="Carrier" [visibleIndex]="1" />
<oge-form-item field="reference" label="Reference" [visibleIndex]="0" />
<oge-form-item
field="trackingNumber"
label="Tracking number"
[visible]="shipment().carrier !== ''"
/>
</oge-form>
`,
})
export class Demo {
protected readonly shipment = signal({
carrier: 'DHL',
reference: 'REF-9',
trackingNumber: '',
});
}