Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.67% covered (warning)
66.67%
82 / 123
36.84% covered (danger)
36.84%
7 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Renderer
66.67% covered (warning)
66.67%
82 / 123
36.84% covered (danger)
36.84%
7 / 19
80.37
0.00% covered (danger)
0.00%
0 / 1
 pageHeader
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 pageFooter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 card
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 notice
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 textField
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 textareaField
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 selectField
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 checkboxField
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 radioField
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 formOpen
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 formClose
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 formTableOpen
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formTableClose
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 kvRow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 noticeAccessible
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 renderLoadingSkeleton
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 renderEmptyState
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 renderHelpIcon
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 pageFooterWithHelpers
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * HTML Renderer helper — the swap point for future UI changes.
4 *
5 * Today: returns HTML strings (uses WordPress core admin markup).
6 * Tomorrow (Y1.5+): can be replaced with React/MUI render() calls
7 * without touching any page-level business logic.
8 *
9 * All admin pages should call these helpers rather than emitting
10 * raw HTML strings inline. This is the seam.
11 *
12 * @package SwapAds\Client\Admin
13 * @since   1.0.0
14 */
15
16declare(strict_types=1);
17
18namespace SwapAds\Client\Admin;
19
20/**
21 * Class Renderer
22 *
23 * @since 1.0.0
24 */
25final class Renderer
26{
27    /**
28     * Open a wrap container with h1.
29     */
30    public static function pageHeader(string $title, string $description = ''): string
31    {
32        $h = '<div class="wrap swapads-page">';
33        $h .= '<h1>' . esc_html($title) . '</h1>';
34        if ($description !== '') {
35            $h .= '<p class="description">' . esc_html($description) . '</p>';
36        }
37        return $h;
38    }
39
40    /**
41     * Close the wrap container.
42     */
43    public static function pageFooter(): string
44    {
45        return '</div>';
46    }
47
48    /**
49     * Card container (styled via the .swapads-card CSS class).
50     *
51     * @param string $title  h2 heading shown at top of card
52     * @param string $body   HTML body content (already escaped by caller)
53     */
54    public static function card(string $title, string $body, string $extraClass = ''): string
55    {
56        $cls = 'swapads-card' . ($extraClass !== '' ? ' ' . $extraClass : '');
57        $h = '<div class="' . esc_attr($cls) . '">';
58        if ($title !== '') {
59            $h .= '<h2>' . esc_html($title) . '</h2>';
60        }
61        $h .= $body;
62        $h .= '</div>';
63        return $h;
64    }
65
66    /**
67     * Notice (success/error/warning).
68     */
69    public static function notice(string $message, string $type = 'success'): string
70    {
71        $type = in_array($type, ['success', 'error', 'warning', 'info'], true) ? $type : 'info';
72        return '<div class="notice notice-' . esc_attr($type) . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
73    }
74
75    /**
76     * Label + text input field.
77     */
78    public static function textField(string $name, string $label, string $value = '', string $placeholder = '', string $helpText = ''): string
79    {
80        $h = '<tr>';
81        $h .= '<th scope="row"><label for="' . esc_attr($name) . '">' . esc_html($label) . '</label></th>';
82        $h .= '<td>';
83        $h .= '<input type="text" id="' . esc_attr($name) . '" name="' . esc_attr($name) . '" '
84            . 'value="' . esc_attr($value) . '" '
85            . 'placeholder="' . esc_attr($placeholder) . '" '
86            . 'class="regular-text">';
87        if ($helpText !== '') {
88            $h .= '<p class="description">' . esc_html($helpText) . '</p>';
89        }
90        $h .= '</td>';
91        $h .= '</tr>';
92        return $h;
93    }
94
95    /**
96     * Label + textarea field.
97     */
98    public static function textareaField(string $name, string $label, string $value = '', int $rows = 4, string $helpText = ''): string
99    {
100        $h = '<tr>';
101        $h .= '<th scope="row"><label for="' . esc_attr($name) . '">' . esc_html($label) . '</label></th>';
102        $h .= '<td>';
103        $h .= '<textarea id="' . esc_attr($name) . '" name="' . esc_attr($name) . '" rows="' . (int) $rows . '" '
104            . 'class="large-text">' . esc_textarea($value) . '</textarea>';
105        if ($helpText !== '') {
106            $h .= '<p class="description">' . esc_html($helpText) . '</p>';
107        }
108        $h .= '</td>';
109        $h .= '</tr>';
110        return $h;
111    }
112
113    /**
114     * Label + select dropdown.
115     *
116     * @param array<int, array{value: string, label: string}> $options
117     */
118    public static function selectField(string $name, string $label, array $options, string $selected = '', string $helpText = ''): string
119    {
120        $h = '<tr>';
121        $h .= '<th scope="row"><label for="' . esc_attr($name) . '">' . esc_html($label) . '</label></th>';
122        $h .= '<td>';
123        $h .= '<select id="' . esc_attr($name) . '" name="' . esc_attr($name) . '">';
124        foreach ($options as $opt) {
125            $sel = selected($selected, $opt['value'], false);
126            $h .= '<option value="' . esc_attr($opt['value']) . '" ' . $sel . '>' . esc_html($opt['label']) . '</option>';
127        }
128        $h .= '</select>';
129        if ($helpText !== '') {
130            $h .= '<p class="description">' . esc_html($helpText) . '</p>';
131        }
132        $h .= '</td>';
133        $h .= '</tr>';
134        return $h;
135    }
136
137    /**
138     * Label + checkbox.
139     */
140    public static function checkboxField(string $name, string $label, bool $checked = false, string $helpText = ''): string
141    {
142        $h = '<tr>';
143        $h .= '<th scope="row">' . esc_html($label) . '</th>';
144        $h .= '<td>';
145        $h .= '<label><input type="checkbox" name="' . esc_attr($name) . '" value="1" ' . checked($checked, true, false) . '> ';
146        $h .= esc_html($label);
147        $h .= '</label>';
148        if ($helpText !== '') {
149            $h .= '<p class="description">' . esc_html($helpText) . '</p>';
150        }
151        $h .= '</td>';
152        $h .= '</tr>';
153        return $h;
154    }
155
156    /**
157     * Radio group.
158     *
159     * @param array<int, array{value: string, label: string}> $options
160     */
161    public static function radioField(string $name, string $label, array $options, string $selected = '', string $helpText = ''): string
162    {
163        $h = '<tr>';
164        $h .= '<th scope="row">' . esc_html($label) . '</th>';
165        $h .= '<td><fieldset>';
166        foreach ($options as $opt) {
167            $h .= '<label style="margin-right:12px;">';
168            $h .= '<input type="radio" name="' . esc_attr($name) . '" value="' . esc_attr($opt['value']) . '" '
169                . checked($selected, $opt['value'], false) . '> ';
170            $h .= esc_html($opt['label']);
171            $h .= '</label>';
172        }
173        $h .= '</fieldset>';
174        if ($helpText !== '') {
175            $h .= '<p class="description">' . esc_html($helpText) . '</p>';
176        }
177        $h .= '</td>';
178        $h .= '</tr>';
179        return $h;
180    }
181
182    /**
183     * Open a form tag pointing to admin-post.php.
184     */
185    public static function formOpen(string $action, string $nonceAction, string $submitLabel = 'Submit'): string
186    {
187        $h = '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
188        $h .= wp_nonce_field($nonceAction, '_wpnonce', true, false);
189        $h .= '<input type="hidden" name="action" value="' . esc_attr($action) . '">';
190        return $h;
191    }
192
193    /**
194     * Close form + emit submit button.
195     */
196    public static function formClose(string $submitLabel = 'Save Changes', string $cancelUrl = ''): string
197    {
198        $h = '</tbody></table>';
199        if ($cancelUrl !== '') {
200            $h .= '<a href="' . esc_url($cancelUrl) . '" class="button">' . esc_html__('Cancel', 'swapads-client') . '</a> ';
201        }
202        $h .= '<button type="submit" class="button button-primary">' . esc_html($submitLabel) . '</button>';
203        $h .= '</form>';
204        return $h;
205    }
206
207    /**
208     * Open a form-table tbody.
209     */
210    public static function formTableOpen(): string
211    {
212        return '<table class="form-table"><tbody>';
213    }
214
215    /**
216     * Close a form-table tbody.
217     */
218    public static function formTableClose(): string
219    {
220        return '</tbody>';
221    }
222
223    /**
224     * Plain key-value row (read-only display).
225     */
226    public static function kvRow(string $label, string $value): string
227    {
228        return '<tr><th scope="row">' . esc_html($label) . '</th><td>' . $value . '</td></tr>';
229    }
230
231    /**
232     * Accessible notice (Feedback + Accessibility principles).
233     *
234     * WP core `notice-*` classes are styled by WP admin. Adds `role="alert"` +
235     * `aria-live="polite"` so screen readers announce the change without
236     * interrupting the user (WCAG 2.1 AA: 4.1.3 Status Messages).
237     *
238     * Use for success/error/warning/info messages that should be announced.
239     *
240     * @param string $type 'success' | 'error' | 'warning' | 'info'
241     */
242    public static function noticeAccessible(string $message, string $type = 'success'): string
243    {
244        $type = in_array($type, ['success', 'error', 'warning', 'info'], true) ? $type : 'info';
245        return '<div class="notice notice-' . esc_attr($type) . ' is-dismissible" '
246             . 'role="alert" aria-live="polite">'
247             . '<p>' . esc_html($message) . '</p>'
248             . '</div>';
249    }
250
251    /**
252     * Loading skeleton placeholder (Feedback principle).
253     *
254     * Whenever a page fetches data from the server, render this skeleton
255     * first so the user sees visual progress within ~200ms (Performance
256     * principle). The skeleton is replaced by real content once the fetch
257     * returns. Reduces perceived latency and prevents layout shift.
258     *
259     * @param string $kind 'tiles' | 'table' | 'paragraph' | 'card'
260     * @param int    $rows Number of skeleton rows (default 3).
261     */
262    public static function renderLoadingSkeleton(string $kind = 'card', int $rows = 3): string
263    {
264        $h = '<div class="swapads-skeleton swapads-skeleton-' . esc_attr($kind) . '" aria-busy="true" aria-live="polite">';
265        for ($i = 0; $i < $rows; $i++) {
266            $h .= '<div class="swapads-skeleton-row"></div>';
267        }
268        $h .= '</div>';
269        return $h;
270    }
271
272    /**
273     * Empty-state block (per UX-SPEC §1.4 + Feedback principle).
274     *
275     * When a list/query has no rows, show a friendly empty state with a
276     * clear next action (CTA). Avoids the "lonely Nothing found" dead-end
277     * that operators hit today.
278     *
279     * @param string      $title    Bold heading (e.g. "No backlinks yet")
280     * @param string      $body     Explanatory text below
281     * @param string|null $ctaUrl   Optional URL for the primary CTA
282     * @param string|null $ctaLabel Optional CTA button label
283     */
284    public static function renderEmptyState(
285        string $title,
286        string $body,
287        ?string $ctaUrl = null,
288        ?string $ctaLabel = null
289    ): string {
290        $h  = '<div class="swapads-empty-state" role="status">';
291        $h .= '<div class="swapads-empty-icon" aria-hidden="true">📅</div>';
292        $h .= '<h3 class="swapads-empty-title">' . esc_html($title) . '</h3>';
293        $h .= '<p class="swapads-empty-body">' . esc_html($body) . '</p>';
294        if ($ctaUrl !== null && $ctaLabel !== null) {
295            $h .= '<a href="' . esc_url($ctaUrl) . '" class="button button-primary">'
296                . esc_html($ctaLabel) . '</a>';
297        }
298        $h .= '</div>';
299        return $h;
300    }
301
302    /**
303     * Inline help icon with tooltip (Discovery principle).
304     *
305     * Renders a small "?" icon that reveals a tooltip on hover/focus. Uses
306     * WP's built-in screen-reader-text pattern for SR-only labels.
307     *
308     * @param string $text Plain-text help shown in the tooltip.
309     * @param string $id   Unique DOM id (caller must ensure uniqueness).
310     */
311    public static function renderHelpIcon(string $text, string $id): string
312    {
313        return '<span class="swapads-help-icon" role="tooltip" id="' . esc_attr($id) . '">'
314             . '?<span class="screen-reader-text">' . esc_html($text) . '</span>'
315             . '</span>';
316    }
317
318    /**
319     * Render the shared page footer (Consistency principle).
320     *
321     * Every page SHOULD call this in lieu of `Renderer::pageFooter()` so the
322     * Help / Docs link is consistent across the plugin. Future global
323     * footer chrome (version, build SHA, support links) lands here.
324     *
325     * @param string $extra HTML to inject before the closing wrap div (rare).
326     */
327    public static function pageFooterWithHelpers(string $extra = ''): string
328    {
329        $h  = $extra;
330        $h .= '<div class="swapads-page-footer" role="contentinfo">';
331        $h .= '<p class="swapads-page-footer-meta">';
332        $h .= '<a href="https://swapads.eu/docs/operator" target="_blank" rel="noopener">'
333             . esc_html__('Help & Docs', 'swapads-client') . '</a>';
334        $h .= ' Â· ';
335        $h .= '<a href="https://swapads.eu/support" target="_blank" rel="noopener">'
336             . esc_html__('Support', 'swapads-client') . '</a>';
337        $h .= '</p>';
338        $h .= '</div>';
339        $h .= '</div>'; // close .swapads-page wrap
340        return $h;
341    }
342}