Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.39% covered (success)
98.39%
61 / 62
96.55% covered (success)
96.55%
28 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Control
98.39% covered (success)
98.39%
61 / 62
96.55% covered (success)
96.55%
28 / 29
37
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 text
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 textarea
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 number
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 checkbox
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 option
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 date
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 time
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 datetime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 color
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hidden
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 code
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 richtext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 image
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 file
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 video
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 iframe
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 youtube
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 blocks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 entries
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reference
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 group
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 repeater
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 element
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 named
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prop
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolve
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 array
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 serialize
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Field;
6
7/**
8 * Declarative editor control descriptor.
9 *
10 * Every field type describes its editor UI as a named control from a
11 * fixed vocabulary interpreted by the panel's generic renderer. The
12 * `element` control loads a custom element (web component) shipped by
13 * a plugin — the escape hatch for UIs the vocabulary cannot express.
14 *
15 * Cross-cutting concerns (label, locale tabs, required marker,
16 * description, width) stay outside the descriptor; they come from the
17 * field's properties() and are rendered by the shared field wrapper.
18 */
19final class Control
20{
21    /** @param array<string, mixed> $props */
22    private function __construct(
23        public readonly string $name,
24        private readonly array $props = [],
25    ) {}
26
27    public static function text(?string $placeholder = null): self
28    {
29        return new self('text', $placeholder === null ? [] : ['placeholder' => $placeholder]);
30    }
31
32    public static function textarea(): self
33    {
34        return new self('textarea');
35    }
36
37    public static function number(
38        int|float|string|null $step = null,
39        int|float|null $min = null,
40        int|float|null $max = null,
41    ): self {
42        return new self('number', array_filter(
43            ['step' => $step, 'min' => $min, 'max' => $max],
44            static fn($value) => $value !== null,
45        ));
46    }
47
48    public static function checkbox(bool $nullable = false): self
49    {
50        return new self('checkbox', ['nullable' => $nullable]);
51    }
52
53    public static function option(string $display = 'select'): self
54    {
55        return new self('option', ['display' => $display]);
56    }
57
58    public static function date(): self
59    {
60        return new self('date');
61    }
62
63    public static function time(): self
64    {
65        return new self('time');
66    }
67
68    public static function datetime(): self
69    {
70        return new self('datetime');
71    }
72
73    public static function color(): self
74    {
75        return new self('color');
76    }
77
78    public static function hidden(): self
79    {
80        return new self('hidden');
81    }
82
83    public static function code(): self
84    {
85        return new self('code');
86    }
87
88    public static function richtext(): self
89    {
90        return new self('richtext');
91    }
92
93    public static function image(): self
94    {
95        return new self('image');
96    }
97
98    public static function file(): self
99    {
100        return new self('file');
101    }
102
103    public static function video(): self
104    {
105        return new self('video');
106    }
107
108    public static function iframe(): self
109    {
110        return new self('iframe');
111    }
112
113    public static function youtube(): self
114    {
115        return new self('youtube');
116    }
117
118    public static function blocks(): self
119    {
120        return new self('blocks');
121    }
122
123    public static function entries(): self
124    {
125        return new self('entries');
126    }
127
128    public static function reference(): self
129    {
130        return new self('reference');
131    }
132
133    /** @param list<array{key: string, label?: string, control: self}> $fields */
134    public static function group(array $fields): self
135    {
136        return new self('group', ['fields' => $fields]);
137    }
138
139    public static function repeater(self $item, ?int $min = null, ?int $max = null): self
140    {
141        return new self('repeater', array_filter(
142            ['item' => $item, 'min' => $min, 'max' => $max],
143            static fn($value) => $value !== null,
144        ));
145    }
146
147    /**
148     * Custom element escape hatch.
149     *
150     * @param string $tag custom element tag, e.g. 'acme-color-picker'
151     * @param string $module module path '{pluginId}/{file}.js', served
152     *                       from the plugin's asset dir
153     */
154    public static function element(string $tag, string $module): self
155    {
156        return new self('element', ['tag' => $tag, 'module' => $module]);
157    }
158
159    /**
160     * A named custom control registered via Registrar::control().
161     */
162    public static function named(string $name): self
163    {
164        return new self($name);
165    }
166
167    public function prop(string $key, mixed $value): self
168    {
169        return new self($this->name, [...$this->props, $key => $value]);
170    }
171
172    /**
173     * Resolve named rich controls to their element form. Primitives,
174     * structural controls and unregistered names pass through; group
175     * and repeater resolve their nested descriptors.
176     */
177    public function resolve(Control\Registry $controls): self
178    {
179        if ($this->name === 'group') {
180            $fields = array_map(
181                static fn(array $field): array => [
182                    ...$field,
183                    'control' => $field['control']->resolve($controls),
184                ],
185                $this->props['fields'] ?? [],
186            );
187
188            return new self('group', [...$this->props, 'fields' => $fields]);
189        }
190
191        if ($this->name === 'repeater') {
192            return new self('repeater', [
193                ...$this->props,
194                'item' => $this->props['item']->resolve($controls),
195            ]);
196        }
197
198        if ($this->name === 'element' || !$controls->has($this->name)) {
199            return $this;
200        }
201
202        ['tag' => $tag, 'module' => $module] = $controls->get($this->name);
203
204        return new self('element', [...$this->props, 'tag' => $tag, 'module' => $module]);
205    }
206
207    /** @return array{name: string, props: array<mixed>} */
208    public function array(): array
209    {
210        return [
211            'name' => $this->name,
212            'props' => self::serialize($this->props),
213        ];
214    }
215
216    /**
217     * @param array<mixed> $values
218     * @return array<mixed>
219     */
220    private static function serialize(array $values): array
221    {
222        return array_map(
223            static fn($value) => match (true) {
224                $value instanceof self => $value->array(),
225                is_array($value) => self::serialize($value),
226                default => $value,
227            },
228            $values,
229        );
230    }
231}