Skip to content

Commit d3f5979

Browse files
committed
FormNode: {form detached name} for layouts that nest other forms
HTML forbids nested <form> tags — browsers discard the inner <form> and pollute the outer form with the orphaned inputs (breaking _do and friends). When a layout needs to embed a component that renders its own form (e.g. a datagrid in a sidebar), `detached` mode emits an empty <form>...</form> at the opening position and links each control rendered via {input}/{label}/n:name back to it using the HTML5 `form="frm-<name>"` attribute (applied lazily in Runtime::item()), so inputs submit to the form regardless of where they live in the DOM. Throws InvalidStateException when detached is used with a non-Form Container or when the form has no id (which would silently produce form="" and break submission).
1 parent 065bd98 commit d3f5979

6 files changed

Lines changed: 217 additions & 9 deletions

File tree

src/Bridges/FormsLatte/Nodes/FormNode.php

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,20 @@
1818

1919

2020
/**
21-
* {form [scope] name [, attributes]} ... {/form}
21+
* {form [scope|detached] name [, attributes]} ... {/form}
2222
* {formContext name} ... {/formContext}
2323
* Renders form tags and initializes form context.
2424
* The `scope` keyword makes {form} skip the <form> tag emission while still pushing the
2525
* form on the stack — semantically identical to {formContext}, but with the canonical
2626
* {form} tag.
27+
* The `detached` keyword emits an empty <form>...</form> at the opening position and links
28+
* every control to it via the HTML5 `form=` attribute. Use it when the layout contains a
29+
* component that renders its own <form> — HTML rejects nested forms, so the controls must
30+
* live outside the <form> element.
2731
*/
2832
class FormNode extends StatementNode
2933
{
30-
private const Modes = ['scope'];
34+
private const Modes = ['scope', 'detached'];
3135

3236
public ExpressionNode $name;
3337
public ArrayNode $attributes;
@@ -68,19 +72,22 @@ public static function create(Tag $tag): \Generator
6872

6973
public function print(PrintContext $context): string
7074
{
75+
$detached = $this->mode === 'detached';
76+
$renderBegin = $this->print ? 'echo $this->global->forms->renderFormBegin(%node) %1.line;' : '';
77+
$renderEnd = $this->print ? 'echo $this->global->forms->renderFormEnd() %4.line;' : '';
78+
7179
return $context->format(
7280
'$this->global->forms->begin($form = '
7381
. ($this->name instanceof StringNode
7482
? '$this->global->uiControl[%node]'
7583
: '(is_object($ʟ_tmp = %node) ? $ʟ_tmp : $this->global->uiControl[$ʟ_tmp])')
84+
. ($detached ? ', detached: true' : '')
7685
. ') %line;'
77-
. ($this->print
78-
? 'echo $this->global->forms->renderFormBegin(%node) %1.line;'
79-
: '')
86+
// In detached mode the <form>...</form> is emitted up front, then content (with controls
87+
// linked back via form= attribute). Otherwise the form wraps the content as usual.
88+
. ($detached ? $renderBegin . $renderEnd : $renderBegin)
8089
. ' %3.node '
81-
. ($this->print
82-
? 'echo $this->global->forms->renderFormEnd() %4.line;'
83-
: '')
90+
. ($detached ? '' : $renderEnd)
8491
. '$this->global->forms->end();'
8592
. "\n\n",
8693
$this->name,

src/Bridges/FormsLatte/Runtime.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class Runtime
2525
/** @var Container[] */
2626
private array $stack = [];
2727

28+
/** @var list<?string> parallel to $stack; non-null element = id of a detached form */
29+
private array $detachedIds = [];
30+
2831

2932
/**
3033
* Renders form begin.
@@ -93,11 +96,18 @@ public function item(object|string|int $item, string $type = Control::class): Co
9396
if (!$item instanceof $type) {
9497
throw new Nette\InvalidArgumentException("Expected instance of $type, " . get_debug_type($item) . ' given.');
9598
}
99+
100+
// Detached mode: link the control back to its <form> via the HTML5 form= attribute,
101+
// since the <form> is emitted outside the visual layout.
102+
$detachedId = end($this->detachedIds);
103+
if ($detachedId !== null && $item instanceof Nette\Forms\Controls\BaseControl) {
104+
$item->setHtmlAttribute('form', $detachedId);
105+
}
96106
return $item;
97107
}
98108

99109

100-
public function begin(Container $form): void
110+
public function begin(Container $form, bool $detached = false): void
101111
{
102112
$this->stack[] = $form;
103113

@@ -107,12 +117,25 @@ public function begin(Container $form): void
107117
$control->setOption('rendered', false);
108118
}
109119
}
120+
121+
$detachedId = null;
122+
if ($detached) {
123+
if (!$form instanceof Form) {
124+
throw new Nette\InvalidStateException('Detached mode requires a Form instance.');
125+
}
126+
$detachedId = (string) $form->getElementPrototype()->id;
127+
if ($detachedId === '') {
128+
throw new Nette\InvalidStateException('Detached form must have an id; pass a name to the Form constructor or set it via getElementPrototype()->id.');
129+
}
130+
}
131+
$this->detachedIds[] = $detachedId;
110132
}
111133

112134

113135
public function end(): void
114136
{
115137
array_pop($this->stack);
138+
array_pop($this->detachedIds);
116139
}
117140

118141

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<form action="" method="post" id="frm-myForm"><input type="hidden" name="_form_" value="myForm"><input type="hidden" name="id" value="42"></form>
2+
3+
<div class="layout">
4+
<label for="frm-myForm-name">Name:</label>
5+
<input type="text" name="name" form="frm-myForm" id="frm-myForm-name">
6+
<input type="text" name="email" form="frm-myForm" id="frm-myForm-email">
7+
<input type="submit" name="_submit" form="frm-myForm" value="Save">
8+
</div>
9+
10+
11+
12+
<form action="" method="post" id="frm-myForm" class="shell"><input type="hidden" name="_form_" value="myForm"><input type="hidden" name="id" value="42"></form>
13+
14+
<div><input type="text" name="name" form="frm-myForm" id="frm-myForm-name"></div>
15+
16+
17+
18+
<form action="" method="post" id="frm-myForm"><input type="hidden" name="_form_" value="myForm"><input type="hidden" name="id" value="42"></form>
19+
20+
<div>
21+
<input type="text" name="name" form="frm-myForm" id="frm-myForm-name">
22+
<form action="/other">
23+
<input name="foo">
24+
</form>
25+
<input type="submit" name="_submit" form="frm-myForm" value="Save">
26+
</div>
27+
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php %A%
2+
$this->global->forms->begin($form = $this->global->uiControl['myForm'], detached: true) /* pos 5:1 */;
3+
echo $this->global->forms->renderFormBegin([]) /* pos 5:1 */;
4+
echo $this->global->forms->renderFormEnd() /* pos 12:1 */;
5+
echo '
6+
<div class="layout">
7+
';
8+
echo ($ʟ_label = $this->global->forms->item('name')->getLabel()) /* pos 7:3 */;
9+
echo '
10+
';
11+
echo $this->global->forms->item('name')->getControl() /* pos 8:3 */;
12+
echo '
13+
';
14+
echo $this->global->forms->item('email')->getControl() /* pos 9:3 */;
15+
echo '
16+
';
17+
echo $this->global->forms->item('submit')->getControl() /* pos 10:3 */;
18+
echo '
19+
</div>
20+
';
21+
$this->global->forms->end();
22+
23+
echo '
24+
25+
26+
';
27+
$this->global->forms->begin($form = $this->global->uiControl['myForm'], detached: true) /* pos 17:1 */;
28+
echo $this->global->forms->renderFormBegin(['class' => 'shell']) /* pos 17:1 */;
29+
echo $this->global->forms->renderFormEnd() /* pos 19:1 */;
30+
echo '
31+
<div>';
32+
echo $this->global->forms->item('name')->getControl() /* pos 18:7 */;
33+
echo '</div>
34+
';
35+
$this->global->forms->end();
36+
37+
echo '
38+
39+
40+
';
41+
$this->global->forms->begin($form = $this->global->uiControl['myForm'], detached: true) /* pos 24:1 */;
42+
echo $this->global->forms->renderFormBegin([]) /* pos 24:1 */;
43+
echo $this->global->forms->renderFormEnd() /* pos 33:1 */;
44+
echo '
45+
<div>
46+
';
47+
echo $this->global->forms->item('name')->getControl() /* pos 26:3 */;
48+
echo '
49+
<form action="/other">
50+
<input name="foo">
51+
</form>
52+
';
53+
echo $this->global->forms->item('submit')->getControl() /* pos 31:3 */;
54+
echo '
55+
</div>
56+
';
57+
$this->global->forms->end();
58+
%A%
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php declare(strict_types=1);
2+
3+
use Nette\Bridges\FormsLatte\FormsExtension;
4+
use Nette\Forms\Form;
5+
use Tester\Assert;
6+
7+
require __DIR__ . '/../bootstrap.php';
8+
9+
10+
$form = new Form('myForm');
11+
$form->addHidden('id', '42');
12+
$form->addText('name', 'Name:');
13+
$form->addText('email', 'Email:');
14+
$form->addSubmit('submit', 'Save');
15+
16+
$latte = new Latte\Engine;
17+
$latte->addExtension(new FormsExtension);
18+
$latte->addProvider('uiControl', ['myForm' => $form]);
19+
20+
Assert::matchFile(
21+
__DIR__ . '/expected/forms.detached.php',
22+
$latte->compile(__DIR__ . '/templates/forms.detached.latte'),
23+
);
24+
25+
Assert::matchFile(
26+
__DIR__ . '/expected/forms.detached.html',
27+
$latte->renderToString(__DIR__ . '/templates/forms.detached.latte'),
28+
);
29+
30+
31+
test('detached requires a Form instance, not a plain Container', function () {
32+
$container = new Nette\Forms\Container;
33+
$container->addText('name');
34+
$latte = new Latte\Engine;
35+
$latte->setLoader(new Latte\Loaders\StringLoader);
36+
$latte->addExtension(new FormsExtension);
37+
$latte->addProvider('uiControl', ['myCont' => $container]);
38+
39+
Assert::exception(
40+
fn() => $latte->renderToString("{form detached myCont}{/form}\n"),
41+
Nette\InvalidStateException::class,
42+
'Detached mode requires a Form instance.',
43+
);
44+
});
45+
46+
47+
test('detached form must have an id', function () {
48+
$form = new Nette\Forms\Form; // no name → no id
49+
$form->addText('name');
50+
$latte = new Latte\Engine;
51+
$latte->setLoader(new Latte\Loaders\StringLoader);
52+
$latte->addExtension(new FormsExtension);
53+
$latte->addProvider('uiControl', ['noName' => $form]);
54+
55+
Assert::exception(
56+
fn() => $latte->renderToString("{form detached noName}{/form}\n"),
57+
Nette\InvalidStateException::class,
58+
'Detached form must have an id%a%',
59+
);
60+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{* {form detached name}: empty <form>...</form> is emitted at the opening position,
2+
controls get form="frm-<formname>" attribute so they submit to that form
3+
even if placed outside the <form> element in the DOM. *}
4+
5+
{form detached myForm}
6+
<div class="layout">
7+
{label name /}
8+
{input name}
9+
{input email}
10+
{input submit}
11+
</div>
12+
{/form}
13+
14+
15+
{* Attributes still apply to the emitted <form> tag. *}
16+
17+
{form detached myForm, class: "shell"}
18+
<div>{input name}</div>
19+
{/form}
20+
21+
22+
{* Real-world use: layout contains another component that emits its own <form>. *}
23+
24+
{form detached myForm}
25+
<div>
26+
{input name}
27+
{* imagine some component rendering its own <form> here *}
28+
<form action="/other">
29+
<input name="foo">
30+
</form>
31+
{input submit}
32+
</div>
33+
{/form}

0 commit comments

Comments
 (0)