-
{props.name}
+
+ {props.name}
{props.children(props.value)}
diff --git a/packages/solid-stage/src/fields/FieldBar.test.tsx b/packages/solid-stage/src/fields/FieldBar.test.tsx
index a9a1101..0beef2e 100644
--- a/packages/solid-stage/src/fields/FieldBar.test.tsx
+++ b/packages/solid-stage/src/fields/FieldBar.test.tsx
@@ -82,7 +82,7 @@ describe('FieldBar', () => {
));
const row = host.querySelector('.stage-field-control') as HTMLElement;
- expect(row.querySelector('.stage-field-label')).toBeNull();
+ expect(row.querySelector('.stage-field-name')).toBeNull();
expect(row.querySelector('.ctl')).not.toBeNull();
dispose();
});
diff --git a/packages/solid-stage/src/fields/LabelField.test.tsx b/packages/solid-stage/src/fields/LabelField.test.tsx
new file mode 100644
index 0000000..187645a
--- /dev/null
+++ b/packages/solid-stage/src/fields/LabelField.test.tsx
@@ -0,0 +1,85 @@
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it } from 'vitest';
+import { createSignal } from 'solid-js';
+import { mount } from '../test-utils';
+import { LabelField } from './LabelField';
+import { FieldBar } from './FieldBar';
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('LabelField', () => {
+ it('renders the text in a label-type control row, compact by default', () => {
+ const { host, dispose } = mount(() =>
);
+ const row = host.querySelector('.stage-field-control') as HTMLElement;
+ expect(row.classList.contains('stage-field-label')).toBe(true);
+ expect(row.classList.contains('stage-compact')).toBe(true);
+ expect(host.querySelector('.stage-label')?.textContent).toBe('A description');
+ dispose();
+ });
+
+ it('has no caption/value split — just one text element, no value cell', () => {
+ const { host, dispose } = mount(() =>
);
+ expect(host.querySelector('.stage-field-value')).toBeNull();
+ expect(host.querySelector('.stage-field-name')).toBeNull();
+ dispose();
+ });
+
+ it('updates reactively when the text accessor changes (no setter API)', () => {
+ const [text, setText] = createSignal('before');
+ const { host, dispose } = mount(() =>
);
+ expect(host.querySelector('.stage-label')?.textContent).toBe('before');
+ setText('after');
+ expect(host.querySelector('.stage-label')?.textContent).toBe('after');
+ dispose();
+ });
+
+ it('applies the align modifier class (left is the unclassed default)', () => {
+ const left = mount(() =>
);
+ expect(left.host.querySelector('.stage-label-center')).toBeNull();
+ expect(left.host.querySelector('.stage-label-right')).toBeNull();
+ left.dispose();
+
+ const center = mount(() =>
);
+ expect(center.host.querySelector('.stage-label.stage-label-center')).not.toBeNull();
+ center.dispose();
+
+ const right = mount(() =>
);
+ expect(right.host.querySelector('.stage-label.stage-label-right')).not.toBeNull();
+ right.dispose();
+ });
+
+ it('honours an explicit layout', () => {
+ const { host, dispose } = mount(() =>
);
+ expect(host.querySelector('.stage-field-control')?.classList.contains('stage-expanded')).toBe(
+ true,
+ );
+ dispose();
+ });
+
+ it('inherits the bar layout from an enclosing FieldBar', () => {
+ const { host, dispose } = mount(() => (
+
+
+
+ ));
+ expect(host.querySelector('.stage-field-control')?.classList.contains('stage-bar')).toBe(true);
+ dispose();
+ });
+
+ it('applies the size tier class', () => {
+ const { host, dispose } = mount(() =>
);
+ expect(host.querySelector('.stage-field-control')?.classList.contains('stage-size-large')).toBe(
+ true,
+ );
+ dispose();
+ });
+
+ it('appends a consumer class onto the text element as a style hook', () => {
+ const { host, dispose } = mount(() =>
);
+ const label = host.querySelector('.stage-label') as HTMLElement;
+ expect(label.classList.contains('app-title')).toBe(true);
+ dispose();
+ });
+});
diff --git a/packages/solid-stage/src/fields/LabelField.tsx b/packages/solid-stage/src/fields/LabelField.tsx
new file mode 100644
index 0000000..d3f6c49
--- /dev/null
+++ b/packages/solid-stage/src/fields/LabelField.tsx
@@ -0,0 +1,68 @@
+import { useContext, type JSX } from 'solid-js';
+import { FieldLayoutContext, type FieldLayout } from './Field';
+import { sizeClassList, type ControlSize } from '../panes/size';
+
+export type LabelAlign = 'left' | 'center' | 'right';
+
+export interface LabelFieldProps {
+ /**
+ * The descriptive text. A plain string, but reactive for free — pass a signal
+ * read (`text={desc()}`) and Solid re-renders on change; no setter API needed.
+ */
+ readonly text: string;
+ /**
+ * Text alignment in `compact` / `expanded` layout (default `left`). Ignored in
+ * `bar` layout, which flows inline and is sized to its content.
+ */
+ readonly align?: LabelAlign | undefined;
+ readonly layout?: FieldLayout | undefined;
+ readonly size?: ControlSize | undefined;
+ /**
+ * Style hook — appended to the text element's class so a consumer can pin
+ * italic / bold / colour from their own stylesheet (the library ships no
+ * inline-style escape hatch). Single or space-separated class tokens.
+ */
+ readonly class?: string | undefined;
+}
+
+/**
+ * Static descriptive text that sits alongside other fields — a module blurb, a
+ * status line, a bar's app title. **Display-only:** no value, no `onChange`,
+ * untouched by `bind()`; the consumer updates it by passing a reactive `text`.
+ *
+ * Unlike value fields it has no caption/value split — it's a single full-width
+ * text element, so it renders a bespoke `.stage-field-control` skeleton rather
+ * than going through `Field` (whose expanded label-row + compact 2-col grid
+ * assume a captioned value). The three layouts:
+ * - `compact` (default): one line, ellipsis-truncated on overflow (explicit
+ * newlines collapse, so they truncate too).
+ * - `expanded`: wraps to as many lines as needed, preserving explicit newlines;
+ * each visual line is one control-height, so the block tiles to whole rows.
+ * - `bar`: one inline line sized to its content, no truncation (the consumer
+ * truncates if needed) — `align` does not apply.
+ */
+export function LabelField(props: LabelFieldProps): JSX.Element {
+ const ctxLayout = useContext(FieldLayoutContext);
+ const layout = (): FieldLayout => props.layout ?? ctxLayout ?? 'compact';
+ return (
+
+
+ {props.text}
+
+
+ );
+}
diff --git a/packages/solid-stage/src/fields/TextareaField.test.tsx b/packages/solid-stage/src/fields/TextareaField.test.tsx
index 1196a8b..79e05f1 100644
--- a/packages/solid-stage/src/fields/TextareaField.test.tsx
+++ b/packages/solid-stage/src/fields/TextareaField.test.tsx
@@ -13,7 +13,7 @@ describe('TextareaField', () => {
const { host, dispose } = mount(() => (
'{}'} onChange={onChange} />
));
- expect(host.querySelector('.stage-field-control .stage-field-label')?.textContent).toBe(
+ expect(host.querySelector('.stage-field-control .stage-field-name')?.textContent).toBe(
'JSON',
);
const ta = host.querySelector('textarea') as HTMLTextAreaElement;
diff --git a/packages/solid-stage/src/index.ts b/packages/solid-stage/src/index.ts
index 1d09e41..821c0cb 100644
--- a/packages/solid-stage/src/index.ts
+++ b/packages/solid-stage/src/index.ts
@@ -23,6 +23,7 @@ export {
export { FieldGroup, type FieldGroupProps } from './fields/FieldGroup';
export { FieldBar, type FieldBarProps } from './fields/FieldBar';
export { TextField, type TextFieldProps } from './fields/TextField';
+export { LabelField, type LabelFieldProps, type LabelAlign } from './fields/LabelField';
export { NumberField, type NumberFieldProps } from './fields/NumberField';
export { TextareaField, type TextareaFieldProps } from './fields/TextareaField';
export { ColorField, type ColorFieldProps } from './fields/ColorField';
diff --git a/packages/solid-stage/src/theme/base.css b/packages/solid-stage/src/theme/base.css
index aa27f45..5f4dd1e 100644
--- a/packages/solid-stage/src/theme/base.css
+++ b/packages/solid-stage/src/theme/base.css
@@ -278,7 +278,7 @@
background: var(--stage-color-module-header-bg);
border: none;
/* The header is a field label rendered bold: same muted colour and tier
- font-size as `.stage-field-label`, so it scales with control size and reads
+ font-size as `.stage-field-name`, so it scales with control size and reads
as the section's label. Both colour + size follow the *inherited* (pane)
tier — like the header height — so a module re-tiering its own body never
resizes its header. (Hover/focus brighten the colour below.) */
diff --git a/packages/solid-stage/src/theme/fields.css b/packages/solid-stage/src/theme/fields.css
index bc4b9d3..9e0b257 100644
--- a/packages/solid-stage/src/theme/fields.css
+++ b/packages/solid-stage/src/theme/fields.css
@@ -24,7 +24,7 @@
font-size: var(--stage-font-size);
}
-.stage-field-label {
+.stage-field-name {
color: var(--stage-color-muted);
overflow: hidden;
text-overflow: ellipsis;
@@ -99,7 +99,7 @@
min-height: var(--stage-control-height);
}
-.stage-bar > .stage-field-label {
+.stage-bar > .stage-field-name {
flex: 0 0 auto;
}
@@ -138,14 +138,14 @@
/* An expanded field's label row is itself one control-height, so an expanded
field ≈ 2 control-heights + the row gap (label row + the value control below,
which pins its own height). The trailing rule centres in it. */
-.stage-field-label-row {
+.stage-field-name-row {
display: flex;
align-items: center;
gap: var(--stage-field-column-gap);
min-height: var(--stage-control-height);
}
-.stage-field-label-row > .stage-field-label {
+.stage-field-name-row > .stage-field-name {
flex: 0 1 auto;
min-width: 0;
}
@@ -163,6 +163,68 @@
width: 100%;
}
+/* LabelField: static descriptive text -------------------------------------
+ *
+ * Display-only text with no caption/value split — a single full-width text
+ * element filling the row, not the label|value grid the other fields use. The
+ * row carries .stage-field-label (the per-type discriminator, freed by renaming
+ * the caption span to .stage-field-name) plus the usual layout class. */
+.stage-label {
+ color: var(--stage-color-label);
+ min-width: 0;
+}
+
+/* Compact: one line, ellipsis on overflow. white-space: nowrap also collapses
+ any explicit newline to a space, so a multiline string truncates here too. */
+.stage-field-control.stage-compact.stage-field-label {
+ display: flex;
+ align-items: center;
+ min-height: var(--stage-control-height);
+}
+
+.stage-compact > .stage-label {
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Expanded: wrap to as many lines as needed and preserve explicit newlines
+ (pre-wrap). line-height pinned to one control-height so K lines occupy
+ exactly K control-heights — the block tiles to whole rows like .stage-icon-row. */
+.stage-field-control.stage-expanded.stage-field-label {
+ display: block;
+}
+
+.stage-expanded > .stage-label {
+ display: block;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ line-height: var(--stage-control-height);
+}
+
+/* Bar: one inline line, content-sized, no truncation (consumer truncates). The
+ element is inline so text-align (the align prop) is a no-op here, as intended. */
+.stage-field-control.stage-bar.stage-field-label {
+ display: inline-flex;
+ align-items: center;
+ width: auto;
+ min-height: var(--stage-control-height);
+}
+
+.stage-bar > .stage-label {
+ white-space: nowrap;
+}
+
+/* Alignment applies to compact / expanded only (bar flows inline). */
+.stage-label-center {
+ text-align: center;
+}
+
+.stage-label-right {
+ text-align: right;
+}
+
/* Text-like inputs (text / number / textarea) ------------------------- */
.stage-input {
width: 100%;
diff --git a/packages/solid-stage/src/theme/tokens.css b/packages/solid-stage/src/theme/tokens.css
index 591e249..fb50ac3 100644
--- a/packages/solid-stage/src/theme/tokens.css
+++ b/packages/solid-stage/src/theme/tokens.css
@@ -96,7 +96,7 @@
/* Max height of SelectField's open listbox before it scrolls. */
--stage-select-listbox-max-height: 240px;
/* Colour of the rule that fills the space after an expanded field's label.
- Defaults to the field label's colour (.stage-field-label uses --stage-color-muted). */
+ Defaults to the field label's colour (.stage-field-name uses --stage-color-muted). */
--stage-field-rule-color: var(--stage-color-muted);
--stage-strip-width: 28px;
diff --git a/playground/src/App.tsx b/playground/src/App.tsx
index 0791c14..1739350 100644
--- a/playground/src/App.tsx
+++ b/playground/src/App.tsx
@@ -6,6 +6,7 @@ import {
FieldGroup,
FieldBar,
TextField,
+ LabelField,
NumberField,
SelectField,
BooleanField,
@@ -115,7 +116,14 @@ export function App(): JSX.Element {
// itself lives in the outer stage's centre; the outer stage has no panes.
const top = (
solid-stage playground }
+ left={
+