Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.36% covered (success)
98.36%
60 / 61
96.43% covered (success)
96.43%
27 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Control
98.36% covered (success)
98.36%
60 / 61
96.43% covered (success)
96.43%
27 / 28
36
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
 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    private function __construct(
22        public readonly string $name,
23        private readonly array $props = [],
24    ) {}
25
26    public static function text(?string $placeholder = null): self
27    {
28        return new self('text', $placeholder === null ? [] : ['placeholder' => $placeholder]);
29    }
30
31    public static function textarea(): self
32    {
33        return new self('textarea');
34    }
35
36    public static function number(
37        int|float|string|null $step = null,
38        int|float|null $min = null,
39        int|float|null $max = null,
40    ): self {
41        return new self('number', array_filter(
42            ['step' => $step, 'min' => $min, 'max' => $max],
43            static fn($value) => $value !== null,
44        ));
45    }
46
47    public static function checkbox(): self
48    {
49        return new self('checkbox');
50    }
51
52    public static function option(string $display = 'select'): self
53    {
54        return new self('option', ['display' => $display]);
55    }
56
57    public static function date(): self
58    {
59        return new self('date');
60    }
61
62    public static function time(): self
63    {
64        return new self('time');
65    }
66
67    public static function datetime(): self
68    {
69        return new self('datetime');
70    }
71
72    public static function hidden(): self
73    {
74        return new self('hidden');
75    }
76
77    public static function code(): self
78    {
79        return new self('code');
80    }
81
82    public static function richtext(): self
83    {
84        return new self('richtext');
85    }
86
87    public static function image(): self
88    {
89        return new self('image');
90    }
91
92    public static function file(): self
93    {
94        return new self('file');
95    }
96
97    public static function video(): self
98    {
99        return new self('video');
100    }
101
102    public static function iframe(): self
103    {
104        return new self('iframe');
105    }
106
107    public static function youtube(): self
108    {
109        return new self('youtube');
110    }
111
112    public static function blocks(): self
113    {
114        return new self('blocks');
115    }
116
117    public static function entries(): self
118    {
119        return new self('entries');
120    }
121
122    public static function reference(): self
123    {
124        return new self('reference');
125    }
126
127    /** @param list<array{key: string, label?: string, control: self}> $fields */
128    public static function group(array $fields): self
129    {
130        return new self('group', ['fields' => $fields]);
131    }
132
133    public static function repeater(self $item, ?int $min = null, ?int $max = null): self
134    {
135        return new self('repeater', array_filter(
136            ['item' => $item, 'min' => $min, 'max' => $max],
137            static fn($value) => $value !== null,
138        ));
139    }
140
141    /**
142     * Custom element escape hatch.
143     *
144     * @param string $tag custom element tag, e.g. 'acme-color-picker'
145     * @param string $module module path '{pluginId}/{file}.js', served
146     *                       from the plugin's asset dir
147     */
148    public static function element(string $tag, string $module): self
149    {
150        return new self('element', ['tag' => $tag, 'module' => $module]);
151    }
152
153    /**
154     * A named custom control registered via Registrar::control().
155     */
156    public static function named(string $name): self
157    {
158        return new self($name);
159    }
160
161    public function prop(string $key, mixed $value): self
162    {
163        return new self($this->name, [...$this->props, $key => $value]);
164    }
165
166    /**
167     * Resolve named rich controls to their element form. Primitives,
168     * structural controls and unregistered names pass through; group
169     * and repeater resolve their nested descriptors.
170     */
171    public function resolve(Control\Registry $controls): self
172    {
173        if ($this->name === 'group') {
174            $fields = array_map(
175                static fn(array $field): array => [
176                    ...$field,
177                    'control' => $field['control']->resolve($controls),
178                ],
179                $this->props['fields'] ?? [],
180            );
181
182            return new self('group', [...$this->props, 'fields' => $fields]);
183        }
184
185        if ($this->name === 'repeater') {
186            return new self('repeater', [
187                ...$this->props,
188                'item' => $this->props['item']->resolve($controls),
189            ]);
190        }
191
192        if ($this->name === 'element' || !$controls->has($this->name)) {
193            return $this;
194        }
195
196        ['tag' => $tag, 'module' => $module] = $controls->get($this->name);
197
198        return new self('element', [...$this->props, 'tag' => $tag, 'module' => $module]);
199    }
200
201    public function array(): array
202    {
203        return [
204            'name' => $this->name,
205            'props' => self::serialize($this->props),
206        ];
207    }
208
209    private static function serialize(array $values): array
210    {
211        return array_map(
212            static fn($value) => match (true) {
213                $value instanceof self => $value->array(),
214                is_array($value) => self::serialize($value),
215                default => $value,
216            },
217            $values,
218        );
219    }
220}