Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
41.55% covered (danger)
41.55%
209 / 503
32.76% covered (danger)
32.76%
19 / 58
CRAP
0.00% covered (danger)
0.00%
0 / 1
HTMLFormField
41.63% covered (danger)
41.63%
209 / 502
32.76% covered (danger)
32.76%
19 / 58
10934.07
0.00% covered (danger)
0.00%
0 / 1
 getInputHTML
n/a
0 / 0
n/a
0 / 0
0
 getInputOOUI
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getInputCodex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canDisplayErrors
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 msg
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 hasVisibleOutput
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNearestField
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 getNearestFieldValue
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getNearestFieldByName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateCondState
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
15
 checkStateRecurse
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
12
 parseCondState
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
13.27
 parseCondStateForClient
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isHidden
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isDisabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 cancelSubmit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
11.06
 filter
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 needsLabel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setShowEmptyLabel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSubmitAttempt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 loadDataFromRequest
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
68.18% covered (warning)
68.18%
30 / 44
0.00% covered (danger)
0.00%
0 / 1
40.04
 getTableRow
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
30
 getDiv
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 getOOUI
60.00% covered (warning)
60.00%
27 / 45
0.00% covered (danger)
0.00%
0 / 1
35.50
 getCodex
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
132
 getClassName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLabelAlignOOUI
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFieldLayoutOOUI
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 shouldInfuseOOUI
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getOOUIModules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRaw
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getVForm
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getInline
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpTextHtmlTable
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getHelpTextHtmlDiv
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getHelpTextHtmlRaw
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpMessages
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
7.19
 getHelpText
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
11.10
 isHelpInline
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getErrorsAndErrorClass
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getErrorsRaw
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
16.67
 getLabel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLabelHtml
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
90
 getDefault
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTooltipAndAccessKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTooltipAndAccessKeyOOUI
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 getAttributes
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 lookupOptionsKeys
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 forceToStringRecursive
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getOptions
42.86% covered (danger)
42.86%
6 / 14
0.00% covered (danger)
0.00%
0 / 1
12.72
 getOptionsOOUI
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 flattenOptions
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 formatErrors
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 getMessage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 skipLoadData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 needsJSForHtml5FormValidation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\HTMLForm;
4
5use HtmlArmor;
6use InvalidArgumentException;
7use MediaWiki\Context\RequestContext;
8use MediaWiki\Html\Html;
9use MediaWiki\HTMLForm\Field\HTMLCheckField;
10use MediaWiki\HTMLForm\Field\HTMLFormFieldCloner;
11use MediaWiki\Json\FormatJson;
12use MediaWiki\Linker\Linker;
13use MediaWiki\Logger\LoggerFactory;
14use MediaWiki\Message\Message;
15use MediaWiki\Request\WebRequest;
16use MediaWiki\Status\Status;
17use StatusValue;
18use Wikimedia\Message\MessageParam;
19use Wikimedia\Message\MessageSpecifier;
20
21/**
22 * The parent class to generate form fields.  Any field type should
23 * be a subclass of this.
24 *
25 * @stable to extend
26 */
27abstract class HTMLFormField {
28    /** @var array|array[] */
29    public $mParams;
30
31    /** @var callable(mixed,array,HTMLForm):(StatusValue|string|bool|Message)|null */
32    protected $mValidationCallback;
33    /** @var callable(mixed,array,HTMLForm):(StatusValue|string|bool|Message)|null */
34    protected $mFilterCallback;
35    /** @var string */
36    protected $mName;
37    /** @var string */
38    protected $mDir;
39    /** @var string String label, as HTML. Set on construction. */
40    protected $mLabel;
41    /** @var string */
42    protected $mID;
43    /** @var string */
44    protected $mClass = '';
45    /** @var string */
46    protected $mVFormClass = '';
47    /** @var string|false */
48    protected $mHelpClass = false;
49    /** @var mixed */
50    protected $mDefault;
51    /** @var array */
52    private $mNotices;
53
54    /**
55     * @var array|null|false
56     */
57    protected $mOptions = false;
58    /** @var bool */
59    protected $mOptionsLabelsNotFromMessage = false;
60    /**
61     * @var array Array to hold params for 'hide-if' or 'disable-if' statements
62     */
63    protected $mCondState = [];
64    /** @var array */
65    protected $mCondStateClass = [];
66
67    /**
68     * @var bool If true will generate an empty div element with no label
69     * @since 1.22
70     */
71    protected $mShowEmptyLabels = true;
72
73    /**
74     * @var HTMLForm|null
75     */
76    public $mParent;
77
78    /**
79     * This function must be implemented to return the HTML to generate
80     * the input object itself.  It should not implement the surrounding
81     * table cells/rows, or labels/help messages.
82     *
83     * @param mixed $value The value to set the input to; eg a default
84     *     text for a text input.
85     *
86     * @return string Valid HTML.
87     */
88    abstract public function getInputHTML( $value );
89
90    /**
91     * Same as getInputHTML, but returns an OOUI object.
92     * Defaults to false, which getOOUI will interpret as "use the HTML version"
93     * @stable to override
94     *
95     * @param string $value
96     * @return \OOUI\Widget|string|false
97     */
98    public function getInputOOUI( $value ) {
99        return false;
100    }
101
102    /**
103     * Same as getInputHTML, but for Codex. This is called by CodexHTMLForm.
104     *
105     * If not overridden, falls back to getInputHTML.
106     *
107     * @param string $value The value to set the input to
108     * @param bool $hasErrors Whether there are validation errors. If set to true, this method
109     *   should apply a CSS class for the error status (e.g. cdx-text-input--status-error)
110     *   if the component used supports that.
111     * @return string HTML
112     */
113    public function getInputCodex( $value, $hasErrors ) {
114        // If not overridden, fall back to getInputHTML()
115        return $this->getInputHTML( $value );
116    }
117
118    /**
119     * True if this field type is able to display errors; false if validation errors need to be
120     * displayed in the main HTMLForm error area.
121     * @stable to override
122     * @return bool
123     */
124    public function canDisplayErrors() {
125        return $this->hasVisibleOutput();
126    }
127
128    /**
129     * Get a translated interface message
130     *
131     * This is a wrapper around $this->mParent->msg() if $this->mParent is set
132     * and wfMessage() otherwise.
133     *
134     * Parameters are the same as wfMessage().
135     *
136     * @param string|string[]|MessageSpecifier $key
137     * @phpcs:ignore Generic.Files.LineLength
138     * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$params
139     *   See Message::params()
140     * @return Message
141     */
142    public function msg( $key, ...$params ) {
143        if ( $this->mParent ) {
144            return $this->mParent->msg( $key, ...$params );
145        }
146        return wfMessage( $key, ...$params );
147    }
148
149    /**
150     * If this field has a user-visible output or not. If not,
151     * it will not be rendered
152     * @stable to override
153     *
154     * @return bool
155     */
156    public function hasVisibleOutput() {
157        return true;
158    }
159
160    /**
161     * Get the field name that will be used for submission.
162     *
163     * @since 1.38
164     * @return string
165     */
166    public function getName() {
167        return $this->mName;
168    }
169
170    /**
171     * Get the closest field matching a given name.
172     *
173     * It can handle array fields like the user would expect. The general
174     * algorithm is to look for $name as a sibling of $this, then a sibling
175     * of $this's parent, and so on.
176     *
177     * @param string $name
178     * @param bool $backCompat Whether to try striping the 'wp' prefix.
179     * @return HTMLFormField
180     */
181    protected function getNearestField( $name, $backCompat = false ) {
182        // When the field is belong to a HTMLFormFieldCloner
183        $cloner = $this->mParams['cloner'] ?? null;
184        if ( $cloner instanceof HTMLFormFieldCloner ) {
185            $field = $cloner->findNearestField( $this, $name );
186            if ( $field ) {
187                return $field;
188            }
189        }
190
191        if ( $backCompat && str_starts_with( $name, 'wp' ) &&
192            !$this->mParent->hasField( $name )
193        ) {
194            // Don't break the existed use cases.
195            return $this->mParent->getField( substr( $name, 2 ) );
196        }
197        return $this->mParent->getField( $name );
198    }
199
200    /**
201     * Fetch a field value from $alldata for the closest field matching a given
202     * name.
203     *
204     * @param array $alldata
205     * @param string $name
206     * @param bool $asDisplay Whether the reverting logic of HTMLCheckField
207     *     should be ignored.
208     * @param bool $backCompat Whether to try striping the 'wp' prefix.
209     * @return mixed
210     */
211    protected function getNearestFieldValue( $alldata, $name, $asDisplay = false, $backCompat = false ) {
212        $field = $this->getNearestField( $name, $backCompat );
213        // When the field belongs to a HTMLFormFieldCloner
214        $cloner = $field->mParams['cloner'] ?? null;
215        if ( $cloner instanceof HTMLFormFieldCloner ) {
216            $value = $cloner->extractFieldData( $field, $alldata );
217        } else {
218            // Note $alldata is an empty array when first rendering a form with a formIdentifier.
219            // In that case, $alldata[$field->mParams['fieldname']] is unset and we use the
220            // field's default value
221            $value = $alldata[$field->mParams['fieldname']] ?? $field->getDefault();
222        }
223
224        // Check invert state for HTMLCheckField
225        if ( $asDisplay && $field instanceof HTMLCheckField && ( $field->mParams['invert'] ?? false ) ) {
226            $value = !$value;
227        }
228
229        return $value;
230    }
231
232    /**
233     * Fetch a field value from $alldata for the closest field matching a given
234     * name.
235     *
236     * @deprecated since 1.38 Use getNearestFieldValue() instead.
237     * @param array $alldata
238     * @param string $name
239     * @param bool $asDisplay
240     * @return string
241     */
242    protected function getNearestFieldByName( $alldata, $name, $asDisplay = false ) {
243        return (string)$this->getNearestFieldValue( $alldata, $name, $asDisplay );
244    }
245
246    /**
247     * Validate the cond-state params, the existence check of fields should
248     * be done later.
249     *
250     * @param array $params
251     */
252    protected function validateCondState( $params ) {
253        $origParams = $params;
254        $op = array_shift( $params );
255
256        $makeException = function ( string $details ) use ( $origParams ): InvalidArgumentException {
257            return new InvalidArgumentException(
258                "Invalid hide-if or disable-if specification for $this->mName" .
259                $details . " in " . var_export( $origParams, true )
260            );
261        };
262
263        switch ( $op ) {
264            case 'NOT':
265                if ( count( $params ) !== 1 ) {
266                    throw $makeException( "NOT takes exactly one parameter" );
267                }
268                // Fall-through intentionally
269
270            case 'AND':
271            case 'OR':
272            case 'NAND':
273            case 'NOR':
274                foreach ( $params as $i => $p ) {
275                    if ( !is_array( $p ) ) {
276                        $type = get_debug_type( $p );
277                        throw $makeException( "Expected array, found $type at index $i" );
278                    }
279                    $this->validateCondState( $p );
280                }
281                break;
282
283            case '===':
284            case '!==':
285                if ( count( $params ) !== 2 ) {
286                    throw $makeException( "$op takes exactly two parameters" );
287                }
288                [ $name, $value ] = $params;
289                if ( !is_string( $name ) || !is_string( $value ) ) {
290                    throw $makeException( "Parameters for $op must be strings" );
291                }
292                break;
293
294            default:
295                throw $makeException( "Unknown operation" );
296        }
297    }
298
299    /**
300     * Helper function for isHidden and isDisabled to handle recursive data structures.
301     *
302     * @param array $alldata
303     * @param array $params
304     * @return bool
305     */
306    protected function checkStateRecurse( array $alldata, array $params ) {
307        $op = array_shift( $params );
308        $valueChk = [ 'AND' => false, 'OR' => true, 'NAND' => false, 'NOR' => true ];
309        $valueRet = [ 'AND' => true, 'OR' => false, 'NAND' => false, 'NOR' => true ];
310
311        switch ( $op ) {
312            case 'AND':
313            case 'OR':
314            case 'NAND':
315            case 'NOR':
316                foreach ( $params as $p ) {
317                    if ( $valueChk[$op] === $this->checkStateRecurse( $alldata, $p ) ) {
318                        return !$valueRet[$op];
319                    }
320                }
321                return $valueRet[$op];
322
323            case 'NOT':
324                return !$this->checkStateRecurse( $alldata, $params[0] );
325
326            case '===':
327            case '!==':
328                [ $field, $value ] = $params;
329                $testValue = (string)$this->getNearestFieldValue( $alldata, $field, true, true );
330                switch ( $op ) {
331                    case '===':
332                        return ( $value === $testValue );
333                    case '!==':
334                        return ( $value !== $testValue );
335                }
336        }
337    }
338
339    /**
340     * Parse the cond-state array to use the field name for submission, since
341     * the key in the form descriptor is never known in HTML. Also check for
342     * field existence here.
343     *
344     * @param array $params
345     * @return mixed[]
346     */
347    protected function parseCondState( $params ) {
348        $op = array_shift( $params );
349
350        switch ( $op ) {
351            case 'AND':
352            case 'OR':
353            case 'NAND':
354            case 'NOR':
355                $ret = [ $op ];
356                foreach ( $params as $p ) {
357                    $ret[] = $this->parseCondState( $p );
358                }
359                return $ret;
360
361            case 'NOT':
362                return [ 'NOT', $this->parseCondState( $params[0] ) ];
363
364            case '===':
365            case '!==':
366                [ $name, $value ] = $params;
367                $field = $this->getNearestField( $name, true );
368                return [ $op, $field->getName(), $value ];
369        }
370    }
371
372    /**
373     * Parse the cond-state array for client-side.
374     *
375     * @return array[]
376     */
377    protected function parseCondStateForClient() {
378        $parsed = [];
379        foreach ( $this->mCondState as $type => $params ) {
380            $parsed[$type] = $this->parseCondState( $params );
381        }
382        return $parsed;
383    }
384
385    /**
386     * Test whether this field is supposed to be hidden, based on the values of
387     * the other form fields.
388     *
389     * @since 1.23
390     * @param array $alldata The data collected from the form
391     * @return bool
392     */
393    public function isHidden( $alldata ) {
394        return isset( $this->mCondState['hide'] ) &&
395            $this->checkStateRecurse( $alldata, $this->mCondState['hide'] );
396    }
397
398    /**
399     * Test whether this field is supposed to be disabled, based on the values of
400     * the other form fields.
401     *
402     * @since 1.38
403     * @param array $alldata The data collected from the form
404     * @return bool
405     */
406    public function isDisabled( $alldata ) {
407        return ( $this->mParams['disabled'] ?? false ) ||
408            $this->isHidden( $alldata ) ||
409            ( isset( $this->mCondState['disable'] )
410                && $this->checkStateRecurse( $alldata, $this->mCondState['disable'] ) );
411    }
412
413    /**
414     * Override this function if the control can somehow trigger a form
415     * submission that shouldn't actually submit the HTMLForm.
416     *
417     * @stable to override
418     * @since 1.23
419     * @param string|array $value The value the field was submitted with
420     * @param array $alldata The data collected from the form
421     *
422     * @return bool True to cancel the submission
423     */
424    public function cancelSubmit( $value, $alldata ) {
425        return false;
426    }
427
428    /**
429     * Override this function to add specific validation checks on the
430     * field input.  Don't forget to call parent::validate() to ensure
431     * that the user-defined callback mValidationCallback is still run
432     * @stable to override
433     *
434     * @param mixed $value The value the field was submitted with
435     * @param array $alldata The data collected from the form
436     *
437     * @return bool|string|Message True on success, or String/Message error to display, or
438     *   false to fail validation without displaying an error.
439     */
440    public function validate( $value, $alldata ) {
441        if ( $this->isHidden( $alldata ) ) {
442            return true;
443        }
444
445        if ( isset( $this->mParams['required'] )
446            && $this->mParams['required'] !== false
447            && ( $value === '' || $value === false || $value === null )
448        ) {
449            return $this->msg( 'htmlform-required' );
450        }
451
452        if ( $this->mValidationCallback === null ) {
453            return true;
454        }
455
456        $p = ( $this->mValidationCallback )( $value, $alldata, $this->mParent );
457
458        if ( $p instanceof StatusValue ) {
459            $language = $this->mParent ? $this->mParent->getLanguage() : RequestContext::getMain()->getLanguage();
460
461            return $p->isGood() ? true : Status::wrap( $p )->getHTML( false, false, $language );
462        }
463
464        return $p;
465    }
466
467    /**
468     * @stable to override
469     *
470     * @param mixed $value
471     * @param mixed[] $alldata
472     *
473     * @return mixed
474     */
475    public function filter( $value, $alldata ) {
476        if ( $this->mFilterCallback !== null ) {
477            $value = ( $this->mFilterCallback )( $value, $alldata, $this->mParent );
478        }
479
480        return $value;
481    }
482
483    /**
484     * Should this field have a label, or is there no input element with the
485     * appropriate id for the label to point to?
486     * @stable to override
487     *
488     * @return bool True to output a label, false to suppress
489     */
490    protected function needsLabel() {
491        return true;
492    }
493
494    /**
495     * Tell the field whether to generate a separate label element if its label
496     * is blank.
497     *
498     * @since 1.22
499     *
500     * @param bool $show Set to false to not generate a label.
501     * @return void
502     */
503    public function setShowEmptyLabel( $show ) {
504        $this->mShowEmptyLabels = $show;
505    }
506
507    /**
508     * Can we assume that the request is an attempt to submit a HTMLForm, as opposed to an attempt to
509     * just view it? This can't normally be distinguished for e.g. checkboxes.
510     *
511     * Returns true if the request was posted and has a field for a CSRF token (wpEditToken), or
512     * has a form identifier (wpFormIdentifier).
513     *
514     * @todo Consider moving this to HTMLForm?
515     * @param WebRequest $request
516     * @return bool
517     */
518    protected function isSubmitAttempt( WebRequest $request ) {
519        // HTMLForm would add a hidden field of edit token for forms that require to be posted.
520        return ( $request->wasPosted() && $request->getCheck( 'wpEditToken' ) )
521            // The identifier matching or not has been checked in HTMLForm::prepareForm()
522            || $request->getCheck( 'wpFormIdentifier' );
523    }
524
525    /**
526     * Get the value that this input has been set to from a posted form,
527     * or the input's default value if it has not been set.
528     * @stable to override
529     *
530     * @param WebRequest $request
531     * @return mixed The value
532     */
533    public function loadDataFromRequest( $request ) {
534        if ( $request->getCheck( $this->mName ) ) {
535            return $request->getText( $this->mName );
536        } else {
537            return $this->getDefault();
538        }
539    }
540
541    /**
542     * Initialise the object
543     *
544     * @stable to call
545     * @param array $params Associative Array. See HTMLForm doc for syntax.
546     *
547     * @since 1.22 The 'label' attribute no longer accepts raw HTML, use 'label-raw' instead
548     */
549    public function __construct( $params ) {
550        $this->mParams = $params;
551
552        if ( isset( $params['parent'] ) && $params['parent'] instanceof HTMLForm ) {
553            $this->mParent = $params['parent'];
554        } else {
555            // Normally parent is added automatically by HTMLForm::factory.
556            // Several field types already assume unconditionally this is always set,
557            // so deprecate manually creating an HTMLFormField without a parent form set.
558            wfDeprecatedMsg(
559                __METHOD__ . ": Constructing an HTMLFormField without a 'parent' parameter",
560                "1.40"
561            );
562        }
563
564        # Generate the label from a message, if possible
565        if ( isset( $params['label-message'] ) ) {
566            $this->mLabel = $this->getMessage( $params['label-message'] )->parse();
567        } elseif ( isset( $params['label'] ) ) {
568            if ( $params['label'] === '&#160;' || $params['label'] === "\u{00A0}" ) {
569                // Apparently some things set &nbsp directly and in an odd format
570                $this->mLabel = "\u{00A0}";
571            } else {
572                $this->mLabel = htmlspecialchars( $params['label'] );
573            }
574        } elseif ( isset( $params['label-raw'] ) ) {
575            $this->mLabel = $params['label-raw'];
576        }
577
578        $this->mName = $params['name'] ?? 'wp' . $params['fieldname'];
579
580        if ( isset( $params['dir'] ) ) {
581            $this->mDir = $params['dir'];
582        }
583
584        $this->mID = "mw-input-{$this->mName}";
585
586        if ( isset( $params['default'] ) ) {
587            $this->mDefault = $params['default'];
588        }
589
590        if ( isset( $params['id'] ) ) {
591            $this->mID = $params['id'];
592        }
593
594        if ( isset( $params['cssclass'] ) ) {
595            $this->mClass = $params['cssclass'];
596        }
597
598        if ( isset( $params['csshelpclass'] ) ) {
599            $this->mHelpClass = $params['csshelpclass'];
600        }
601
602        if ( isset( $params['validation-callback'] ) ) {
603            $this->mValidationCallback = $params['validation-callback'];
604        }
605
606        if ( isset( $params['filter-callback'] ) ) {
607            $this->mFilterCallback = $params['filter-callback'];
608        }
609
610        if ( isset( $params['hidelabel'] ) ) {
611            $this->mShowEmptyLabels = false;
612        }
613        if ( isset( $params['notices'] ) ) {
614            $this->mNotices = $params['notices'];
615        }
616
617        if ( isset( $params['hide-if'] ) && $params['hide-if'] ) {
618            $this->validateCondState( $params['hide-if'] );
619            $this->mCondState['hide'] = $params['hide-if'];
620            $this->mCondStateClass[] = 'mw-htmlform-hide-if';
621        }
622        if ( !( isset( $params['disabled'] ) && $params['disabled'] ) &&
623            isset( $params['disable-if'] ) && $params['disable-if']
624        ) {
625            $this->validateCondState( $params['disable-if'] );
626            $this->mCondState['disable'] = $params['disable-if'];
627            $this->mCondStateClass[] = 'mw-htmlform-disable-if';
628        }
629    }
630
631    /**
632     * Get the complete table row for the input, including help text,
633     * labels, and whatever.
634     * @stable to override
635     *
636     * @param string $value The value to set the input to.
637     *
638     * @return string Complete HTML table row.
639     */
640    public function getTableRow( $value ) {
641        [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
642        $inputHtml = $this->getInputHTML( $value );
643        $fieldType = $this->getClassName();
644        $helptext = $this->getHelpTextHtmlTable( $this->getHelpText() );
645        $cellAttributes = [];
646        $rowAttributes = [];
647        $rowClasses = '';
648
649        if ( !empty( $this->mParams['vertical-label'] ) ) {
650            $cellAttributes['colspan'] = 2;
651            $verticalLabel = true;
652        } else {
653            $verticalLabel = false;
654        }
655
656        $label = $this->getLabelHtml( $cellAttributes );
657
658        $field = Html::rawElement(
659            'td',
660            [ 'class' => 'mw-input' ] + $cellAttributes,
661            $inputHtml . "\n$errors"
662        );
663
664        if ( $this->mCondState ) {
665            $rowAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
666            $rowClasses .= implode( ' ', $this->mCondStateClass );
667            if ( $this->isHidden( $this->mParent->mFieldData ) ) {
668                $rowClasses .= ' mw-htmlform-hide-if-hidden';
669            }
670        }
671
672        if ( $verticalLabel ) {
673            $html = Html::rawElement( 'tr',
674                $rowAttributes + [ 'class' => "mw-htmlform-vertical-label $rowClasses" ], $label );
675            $html .= Html::rawElement( 'tr',
676                $rowAttributes + [
677                    'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
678                ],
679                $field );
680        } else {
681            $html = Html::rawElement( 'tr',
682                $rowAttributes + [
683                    'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
684                ],
685                $label . $field );
686        }
687
688        return $html . $helptext;
689    }
690
691    /**
692     * Get the complete div for the input, including help text,
693     * labels, and whatever.
694     * @stable to override
695     * @since 1.20
696     *
697     * @param string $value The value to set the input to.
698     *
699     * @return string Complete HTML table row.
700     */
701    public function getDiv( $value ) {
702        [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
703        $inputHtml = $this->getInputHTML( $value );
704        $fieldType = $this->getClassName();
705        $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText() );
706        $cellAttributes = [];
707        $label = $this->getLabelHtml( $cellAttributes );
708
709        $outerDivClass = [
710            'mw-input',
711            'mw-htmlform-nolabel' => ( $label === '' )
712        ];
713
714        $horizontalLabel = $this->mParams['horizontal-label'] ?? false;
715
716        if ( $horizontalLabel ) {
717            $field = "\u{00A0}" . $inputHtml . "\n$errors";
718        } else {
719            $field = Html::rawElement(
720                'div',
721                // @phan-suppress-next-line PhanUselessBinaryAddRight
722                [ 'class' => $outerDivClass ] + $cellAttributes,
723                $inputHtml . "\n$errors"
724            );
725        }
726
727        $wrapperAttributes = [ 'class' => [
728            "mw-htmlform-field-$fieldType",
729            $this->mClass,
730            $this->mVFormClass,
731            $errorClass,
732        ] ];
733        if ( $this->mCondState ) {
734            $wrapperAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
735            $wrapperAttributes['class'] = array_merge( $wrapperAttributes['class'], $this->mCondStateClass );
736            if ( $this->isHidden( $this->mParent->mFieldData ) ) {
737                $wrapperAttributes['class'][] = 'mw-htmlform-hide-if-hidden';
738            }
739        }
740        return Html::rawElement( 'div', $wrapperAttributes, $label . $field ) .
741            $helptext;
742    }
743
744    /**
745     * Get the OOUI version of the div. Falls back to getDiv by default.
746     * @stable to override
747     * @since 1.26
748     *
749     * @param string $value The value to set the input to.
750     *
751     * @return \OOUI\FieldLayout
752     */
753    public function getOOUI( $value ) {
754        $inputField = $this->getInputOOUI( $value );
755
756        if ( !$inputField ) {
757            // This field doesn't have an OOUI implementation yet at all. Fall back to getDiv() to
758            // generate the whole field, label and errors and all, then wrap it in a Widget.
759            // It might look weird, but it'll work OK.
760            return $this->getFieldLayoutOOUI(
761                new \OOUI\Widget( [ 'content' => new \OOUI\HtmlSnippet( $this->getDiv( $value ) ) ] ),
762                [ 'align' => 'top' ]
763            );
764        }
765
766        $infusable = true;
767        if ( is_string( $inputField ) ) {
768            // We have an OOUI implementation, but it's not proper, and we got a load of HTML.
769            // Cheat a little and wrap it in a widget. It won't be infusable, though, since client-side
770            // JavaScript doesn't know how to rebuilt the contents.
771            $inputField = new \OOUI\Widget( [ 'content' => new \OOUI\HtmlSnippet( $inputField ) ] );
772            $infusable = false;
773        }
774
775        $fieldType = $this->getClassName();
776        $help = $this->getHelpText();
777        $errors = $this->getErrorsRaw( $value );
778        foreach ( $errors as &$error ) {
779            $error = new \OOUI\HtmlSnippet( $error );
780        }
781
782        $config = [
783            'classes' => [ "mw-htmlform-field-$fieldType" ],
784            'align' => $this->getLabelAlignOOUI(),
785            'help' => ( $help !== null && $help !== '' ) ? new \OOUI\HtmlSnippet( $help ) : null,
786            'errors' => $errors,
787            'infusable' => $infusable,
788            'helpInline' => $this->isHelpInline(),
789            'notices' => $this->mNotices ?: [],
790        ];
791        if ( $this->mClass !== '' ) {
792            $config['classes'][] = $this->mClass;
793        }
794
795        $preloadModules = false;
796
797        if ( $infusable && $this->shouldInfuseOOUI() ) {
798            $preloadModules = true;
799            $config['classes'][] = 'mw-htmlform-autoinfuse';
800        }
801        if ( $this->mCondState ) {
802            $config['classes'] = array_merge( $config['classes'], $this->mCondStateClass );
803            if ( $this->isHidden( $this->mParent->mFieldData ) ) {
804                $config['classes'][] = 'mw-htmlform-hide-if-hidden';
805            }
806        }
807
808        // the element could specify, that the label doesn't need to be added
809        $label = $this->getLabel();
810        if ( $label && $label !== "\u{00A0}" && $label !== '&#160;' ) {
811            $config['label'] = new \OOUI\HtmlSnippet( $label );
812        }
813
814        if ( $this->mCondState ) {
815            $preloadModules = true;
816            $config['condState'] = $this->parseCondStateForClient();
817        }
818
819        $config['modules'] = $this->getOOUIModules();
820
821        if ( $preloadModules ) {
822            $this->mParent->getOutput()->addModules( 'mediawiki.htmlform.ooui' );
823            $this->mParent->getOutput()->addModules( $this->getOOUIModules() );
824        }
825
826        return $this->getFieldLayoutOOUI( $inputField, $config );
827    }
828
829    /**
830     * Get the Codex version of the div.
831     * @since 1.42
832     *
833     * @param string $value The value to set the input to.
834     * @return string HTML
835     */
836    public function getCodex( $value ) {
837        $isDisabled = ( $this->mParams['disabled'] ?? false );
838
839        // Label
840        $labelDiv = '';
841        $labelValue = trim( $this->getLabel() );
842        // For weird historical reasons, a non-breaking space is treated as an empty label
843        // Check for both a literal nbsp ("\u{00A0}") and the HTML-encoded version
844        if ( $labelValue !== '' && $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' ) {
845            $labelFor = $this->needsLabel() ? [ 'for' => $this->mID ] : [];
846            $labelClasses = [ 'cdx-label' ];
847            if ( $isDisabled ) {
848                $labelClasses[] = 'cdx-label--disabled';
849            }
850            // <div class="cdx-label">
851            $labelDiv = Html::rawElement( 'div', [ 'class' => $labelClasses ],
852                // <label class="cdx-label__label" for="ID">
853                Html::rawElement( 'label', [ 'class' => 'cdx-label__label' ] + $labelFor,
854                    // <span class="cdx-label__label__text">
855                    Html::rawElement( 'span', [ 'class' => 'cdx-label__label__text' ],
856                        $labelValue
857                    )
858                )
859            );
860        }
861
862        // Help text
863        // <div class="cdx-field__help-text">
864        $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText(), [ 'cdx-field__help-text' ] );
865
866        // Validation message
867        // <div class="cdx-field__validation-message">
868        // $errors is a <div class="cdx-message">
869        // FIXME right now this generates a block message (cdx-message--block), we want an inline message instead
870        $validationMessage = '';
871        [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
872        if ( $errors !== '' ) {
873            $validationMessage = Html::rawElement( 'div', [ 'class' => 'cdx-field__validation-message' ],
874                $errors
875            );
876        }
877
878        // Control
879        $inputHtml = $this->getInputCodex( $value, $errors !== '' );
880        // <div class="cdx-field__control cdx-field__control--has-help-text">
881        $controlClasses = [ 'cdx-field__control' ];
882        if ( $helptext ) {
883            $controlClasses[] = 'cdx-field__control--has-help-text';
884        }
885        $control = Html::rawElement( 'div', [ 'class' => $controlClasses ], $inputHtml );
886
887        // <div class="cdx-field">
888        $fieldClasses = [
889            "mw-htmlform-field-{$this->getClassName()}",
890            $this->mClass,
891            $errorClass,
892            'cdx-field'
893        ];
894        if ( $isDisabled ) {
895            $fieldClasses[] = 'cdx-field--disabled';
896        }
897        $fieldAttributes = [];
898        // Set data attribute and CSS class for client side handling of hide-if / disable-if
899        if ( $this->mCondState ) {
900            $fieldAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
901            $fieldClasses = array_merge( $fieldClasses, $this->mCondStateClass );
902            if ( $this->isHidden( $this->mParent->mFieldData ) ) {
903                $fieldClasses[] = 'mw-htmlform-hide-if-hidden';
904            }
905        }
906
907        return Html::rawElement( 'div', [ 'class' => $fieldClasses ] + $fieldAttributes,
908            $labelDiv . $control . $helptext . $validationMessage
909        );
910    }
911
912    /**
913     * Gets the non namespaced class name
914     *
915     * @since 1.36
916     *
917     * @return string
918     */
919    protected function getClassName() {
920        $name = explode( '\\', static::class );
921        return end( $name );
922    }
923
924    /**
925     * Get label alignment when generating field for OOUI.
926     * @stable to override
927     * @return string 'left', 'right', 'top' or 'inline'
928     */
929    protected function getLabelAlignOOUI() {
930        return 'top';
931    }
932
933    /**
934     * Get a FieldLayout (or subclass thereof) to wrap this field in when using OOUI output.
935     * @param \OOUI\Widget $inputField
936     * @param array $config
937     * @return \OOUI\FieldLayout
938     */
939    protected function getFieldLayoutOOUI( $inputField, $config ) {
940        return new HTMLFormFieldLayout( $inputField, $config );
941    }
942
943    /**
944     * Whether the field should be automatically infused. Note that all OOUI HTMLForm fields are
945     * infusable (you can call OO.ui.infuse() on them), but not all are infused by default, since
946     * there is no benefit in doing it e.g. for buttons and it's a small performance hit on page load.
947     * @stable to override
948     *
949     * @return bool
950     */
951    protected function shouldInfuseOOUI() {
952        // Always infuse fields with popup help text, since the interface for it is nicer with JS
953        return !$this->isHelpInline() && $this->getHelpMessages();
954    }
955
956    /**
957     * Get the list of extra ResourceLoader modules which must be loaded client-side before it's
958     * possible to infuse this field's OOUI widget.
959     * @stable to override
960     *
961     * @return string[]
962     */
963    protected function getOOUIModules() {
964        return [];
965    }
966
967    /**
968     * Get the complete raw fields for the input, including help text,
969     * labels, and whatever.
970     * @stable to override
971     * @since 1.20
972     *
973     * @param string $value The value to set the input to.
974     *
975     * @return string Complete HTML table row.
976     */
977    public function getRaw( $value ) {
978        [ $errors, ] = $this->getErrorsAndErrorClass( $value );
979        return "\n" . $errors .
980            $this->getLabelHtml() .
981            $this->getInputHTML( $value ) .
982            $this->getHelpTextHtmlRaw( $this->getHelpText() );
983    }
984
985    /**
986     * Get the complete field for the input, including help text,
987     * labels, and whatever. Fall back from 'vform' to 'div' when not overridden.
988     *
989     * @stable to override
990     * @since 1.25
991     * @param string $value The value to set the input to.
992     * @return string Complete HTML field.
993     */
994    public function getVForm( $value ) {
995        // Ewwww
996        $this->mVFormClass = ' mw-ui-vform-field';
997        return $this->getDiv( $value );
998    }
999
1000    /**
1001     * Get the complete field as an inline element.
1002     * @stable to override
1003     * @since 1.25
1004     * @param string $value The value to set the input to.
1005     * @return string Complete HTML inline element
1006     */
1007    public function getInline( $value ) {
1008        [ $errors, ] = $this->getErrorsAndErrorClass( $value );
1009        return "\n" . $errors .
1010            $this->getLabelHtml() .
1011            "\u{00A0}" .
1012            $this->getInputHTML( $value ) .
1013            $this->getHelpTextHtmlDiv( $this->getHelpText() );
1014    }
1015
1016    /**
1017     * Generate help text HTML in table format
1018     * @since 1.20
1019     *
1020     * @param string|null $helptext
1021     * @return string
1022     */
1023    public function getHelpTextHtmlTable( $helptext ) {
1024        if ( $helptext === null ) {
1025            return '';
1026        }
1027
1028        $rowAttributes = [];
1029        if ( $this->mCondState ) {
1030            $rowAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
1031            $rowAttributes['class'] = $this->mCondStateClass;
1032        }
1033
1034        $tdClasses = [ 'htmlform-tip' ];
1035        if ( $this->mHelpClass !== false ) {
1036            $tdClasses[] = $this->mHelpClass;
1037        }
1038        return Html::rawElement( 'tr', $rowAttributes,
1039            Html::rawElement( 'td', [ 'colspan' => 2, 'class' => $tdClasses ], $helptext )
1040        );
1041    }
1042
1043    /**
1044     * Generate help text HTML in div format
1045     * @since 1.20
1046     *
1047     * @param string|null $helptext
1048     * @param string[] $cssClasses
1049     *
1050     * @return string
1051     */
1052    public function getHelpTextHtmlDiv( $helptext, $cssClasses = [] ) {
1053        if ( $helptext === null ) {
1054            return '';
1055        }
1056
1057        $wrapperAttributes = [
1058            'class' => array_merge( $cssClasses, [ 'htmlform-tip' ] ),
1059        ];
1060        if ( $this->mHelpClass !== false ) {
1061            $wrapperAttributes['class'][] = $this->mHelpClass;
1062        }
1063        if ( $this->mCondState ) {
1064            $wrapperAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
1065            $wrapperAttributes['class'] = array_merge( $wrapperAttributes['class'], $this->mCondStateClass );
1066        }
1067        return Html::rawElement( 'div', $wrapperAttributes, $helptext );
1068    }
1069
1070    /**
1071     * Generate help text HTML formatted for raw output
1072     * @since 1.20
1073     *
1074     * @param string|null $helptext
1075     * @return string
1076     */
1077    public function getHelpTextHtmlRaw( $helptext ) {
1078        return $this->getHelpTextHtmlDiv( $helptext );
1079    }
1080
1081    private function getHelpMessages(): array {
1082        if ( isset( $this->mParams['help-message'] ) ) {
1083            return [ $this->mParams['help-message'] ];
1084        } elseif ( isset( $this->mParams['help-messages'] ) ) {
1085            return $this->mParams['help-messages'];
1086        } elseif ( isset( $this->mParams['help-raw'] ) ) {
1087            return [ new HtmlArmor( $this->mParams['help-raw'] ) ];
1088        } elseif ( isset( $this->mParams['help'] ) ) {
1089            // @deprecated since 1.43, use 'help-raw' key instead
1090            return [ new HtmlArmor( $this->mParams['help'] ) ];
1091        }
1092
1093        return [];
1094    }
1095
1096    /**
1097     * Determine the help text to display
1098     * @stable to override
1099     * @since 1.20
1100     * @return string|null HTML
1101     */
1102    public function getHelpText() {
1103        $html = [];
1104
1105        foreach ( $this->getHelpMessages() as $msg ) {
1106            if ( $msg instanceof HtmlArmor ) {
1107                $html[] = HtmlArmor::getHtml( $msg );
1108            } else {
1109                $msg = $this->getMessage( $msg );
1110                if ( $msg->exists() ) {
1111                    $html[] = $msg->parse();
1112                }
1113            }
1114        }
1115
1116        return $html ? implode( $this->msg( 'word-separator' )->escaped(), $html ) : null;
1117    }
1118
1119    /**
1120     * Determine if the help text should be displayed inline.
1121     *
1122     * Only applies to OOUI forms.
1123     *
1124     * @since 1.31
1125     * @return bool
1126     */
1127    public function isHelpInline() {
1128        return $this->mParams['help-inline'] ?? true;
1129    }
1130
1131    /**
1132     * Determine form errors to display and their classes
1133     * @since 1.20
1134     *
1135     * phan-taint-check gets confused with returning both classes
1136     * and errors and thinks double escaping is happening, so specify
1137     * that return value has no taint.
1138     *
1139     * @param string $value The value of the input
1140     * @return array [ $errors, $errorClass ]
1141     * @return-taint none
1142     */
1143    public function getErrorsAndErrorClass( $value ) {
1144        $errors = $this->validate( $value, $this->mParent->mFieldData );
1145
1146        if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
1147            return [ '', '' ];
1148        }
1149
1150        return [ self::formatErrors( $errors ), 'mw-htmlform-invalid-input' ];
1151    }
1152
1153    /**
1154     * Determine form errors to display, returning them in an array.
1155     *
1156     * @since 1.26
1157     * @param string $value The value of the input
1158     * @return string[] Array of error HTML strings
1159     */
1160    public function getErrorsRaw( $value ) {
1161        $errors = $this->validate( $value, $this->mParent->mFieldData );
1162
1163        if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
1164            return [];
1165        }
1166
1167        if ( !is_array( $errors ) ) {
1168            $errors = [ $errors ];
1169        }
1170        foreach ( $errors as &$error ) {
1171            if ( $error instanceof Message ) {
1172                $error = $error->parse();
1173            }
1174        }
1175
1176        return $errors;
1177    }
1178
1179    /**
1180     * @stable to override
1181     * @return string HTML
1182     */
1183    public function getLabel() {
1184        return $this->mLabel ?? '';
1185    }
1186
1187    /**
1188     * @stable to override
1189     * @param array $cellAttributes
1190     *
1191     * @return string
1192     */
1193    public function getLabelHtml( $cellAttributes = [] ) {
1194        # Don't output a for= attribute for labels with no associated input.
1195        # Kind of hacky here, possibly we don't want these to be <label>s at all.
1196        $for = $this->needsLabel() ? [ 'for' => $this->mID ] : [];
1197
1198        $labelValue = trim( $this->getLabel() );
1199        $hasLabel = $labelValue !== '' && $labelValue !== "\u{00A0}" && $labelValue !== '&#160;';
1200
1201        $displayFormat = $this->mParent->getDisplayFormat();
1202        $horizontalLabel = $this->mParams['horizontal-label'] ?? false;
1203
1204        if ( $displayFormat === 'table' ) {
1205            return Html::rawElement( 'td',
1206                    [ 'class' => 'mw-label' ] + $cellAttributes,
1207                    Html::rawElement( 'label', $for, $labelValue ) );
1208        } elseif ( $hasLabel || $this->mShowEmptyLabels ) {
1209            if ( $displayFormat === 'div' && !$horizontalLabel ) {
1210                return Html::rawElement( 'div',
1211                        [ 'class' => 'mw-label' ] + $cellAttributes,
1212                        Html::rawElement( 'label', $for, $labelValue ) );
1213            } else {
1214                return Html::rawElement( 'label', $for, $labelValue );
1215            }
1216        }
1217
1218        return '';
1219    }
1220
1221    /**
1222     * @stable to override
1223     * @return mixed
1224     */
1225    public function getDefault() {
1226        return $this->mDefault ?? null;
1227    }
1228
1229    /**
1230     * Returns the attributes required for the tooltip and accesskey, for Html::element() etc.
1231     *
1232     * @return array Attributes
1233     */
1234    public function getTooltipAndAccessKey() {
1235        if ( empty( $this->mParams['tooltip'] ) ) {
1236            return [];
1237        }
1238
1239        return Linker::tooltipAndAccesskeyAttribs( $this->mParams['tooltip'] );
1240    }
1241
1242    /**
1243     * Returns the attributes required for the tooltip and accesskey, for OOUI widgets' config.
1244     *
1245     * @return array Attributes
1246     */
1247    public function getTooltipAndAccessKeyOOUI() {
1248        if ( empty( $this->mParams['tooltip'] ) ) {
1249            return [];
1250        }
1251
1252        return [
1253            'title' => Linker::titleAttrib( $this->mParams['tooltip'] ),
1254            'accessKey' => Linker::accesskey( $this->mParams['tooltip'] ),
1255        ];
1256    }
1257
1258    /**
1259     * Returns the given attributes from the parameters
1260     * @stable to override
1261     *
1262     * @param array $list List of attributes to get
1263     * @return array Attributes
1264     */
1265    public function getAttributes( array $list ) {
1266        static $boolAttribs = [ 'disabled', 'required', 'autofocus', 'multiple', 'readonly' ];
1267
1268        $ret = [];
1269        foreach ( $list as $key ) {
1270            if ( in_array( $key, $boolAttribs ) ) {
1271                if ( !empty( $this->mParams[$key] ) ) {
1272                    $ret[$key] = '';
1273                }
1274            } elseif ( isset( $this->mParams[$key] ) ) {
1275                $ret[$key] = $this->mParams[$key];
1276            }
1277        }
1278
1279        return $ret;
1280    }
1281
1282    /**
1283     * Given an array of msg-key => value mappings, returns an array with keys
1284     * being the message texts. It also forces values to strings.
1285     *
1286     * @param array $options
1287     * @param bool $needsParse
1288     * @return array
1289     * @return-taint tainted
1290     */
1291    private function lookupOptionsKeys( $options, $needsParse ) {
1292        $ret = [];
1293        foreach ( $options as $key => $value ) {
1294            $msg = $this->msg( $key );
1295            $msgAsText = $needsParse ? $msg->parse() : $msg->plain();
1296            if ( array_key_exists( $msgAsText, $ret ) ) {
1297                LoggerFactory::getInstance( 'translation-problem' )->error(
1298                    'The option that uses the message key {msg_key_one} has the same translation as ' .
1299                    'another option in {lang}. This means that {msg_key_one} will not be used as an option.',
1300                    [
1301                        'msg_key_one' => $key,
1302                        'lang' => $this->mParent ?
1303                            $this->mParent->getLanguageCode()->toBcp47Code() :
1304                            RequestContext::getMain()->getLanguageCode()->toBcp47Code(),
1305                    ]
1306                );
1307                continue;
1308            }
1309            $ret[$msgAsText] = is_array( $value )
1310                ? $this->lookupOptionsKeys( $value, $needsParse )
1311                : strval( $value );
1312        }
1313        return $ret;
1314    }
1315
1316    /**
1317     * Recursively forces values in an array to strings, because issues arise
1318     * with integer 0 as a value.
1319     *
1320     * @param array|string $array
1321     * @return array|string
1322     */
1323    public static function forceToStringRecursive( $array ) {
1324        if ( is_array( $array ) ) {
1325            return array_map( [ __CLASS__, 'forceToStringRecursive' ], $array );
1326        } else {
1327            return strval( $array );
1328        }
1329    }
1330
1331    /**
1332     * Fetch the array of options from the field's parameters. In order, this
1333     * checks 'options-messages', 'options', then 'options-message'.
1334     *
1335     * @return array|null
1336     */
1337    public function getOptions() {
1338        if ( $this->mOptions === false ) {
1339            if ( array_key_exists( 'options-messages', $this->mParams ) ) {
1340                $needsParse = $this->mParams['options-messages-parse'] ?? false;
1341                if ( $needsParse ) {
1342                    $this->mOptionsLabelsNotFromMessage = true;
1343                }
1344                $this->mOptions = $this->lookupOptionsKeys( $this->mParams['options-messages'], $needsParse );
1345            } elseif ( array_key_exists( 'options', $this->mParams ) ) {
1346                $this->mOptionsLabelsNotFromMessage = true;
1347                $this->mOptions = self::forceToStringRecursive( $this->mParams['options'] );
1348            } elseif ( array_key_exists( 'options-message', $this->mParams ) ) {
1349                $message = $this->getMessage( $this->mParams['options-message'] )->inContentLanguage()->plain();
1350                $this->mOptions = Html::listDropdownOptions( $message );
1351            } else {
1352                $this->mOptions = null;
1353            }
1354        }
1355
1356        return $this->mOptions;
1357    }
1358
1359    /**
1360     * Get options and make them into arrays suitable for OOUI.
1361     * @stable to override
1362     * @return array|null Options for inclusion in a select or whatever.
1363     */
1364    public function getOptionsOOUI() {
1365        $oldoptions = $this->getOptions();
1366
1367        if ( $oldoptions === null ) {
1368            return null;
1369        }
1370
1371        return Html::listDropdownOptionsOoui( $oldoptions );
1372    }
1373
1374    /**
1375     * flatten an array of options to a single array, for instance,
1376     * a set of "<options>" inside "<optgroups>".
1377     *
1378     * @param array $options Associative Array with values either Strings or Arrays
1379     * @return array Flattened input
1380     */
1381    public static function flattenOptions( $options ) {
1382        $flatOpts = [];
1383
1384        foreach ( $options as $value ) {
1385            if ( is_array( $value ) ) {
1386                $flatOpts = array_merge( $flatOpts, self::flattenOptions( $value ) );
1387            } else {
1388                $flatOpts[] = $value;
1389            }
1390        }
1391
1392        return $flatOpts;
1393    }
1394
1395    /**
1396     * Formats one or more errors as accepted by field validation-callback.
1397     *
1398     * @param string|Message|array $errors Array of strings or Message instances
1399     * To work around limitations in phan-taint-check the calling
1400     * class has taintedness disabled. So instead we pretend that
1401     * this method outputs html, since the result is eventually
1402     * outputted anyways without escaping and this allows us to verify
1403     * stuff is safe even though the caller has taintedness cleared.
1404     * @param-taint $errors exec_html
1405     * @return string HTML
1406     * @since 1.18
1407     */
1408    protected static function formatErrors( $errors ) {
1409        if ( is_array( $errors ) && count( $errors ) === 1 ) {
1410            $errors = array_shift( $errors );
1411        }
1412
1413        if ( is_array( $errors ) ) {
1414            foreach ( $errors as &$error ) {
1415                $error = Html::rawElement( 'li', [],
1416                    $error instanceof Message ? $error->parse() : $error
1417                );
1418            }
1419            $errors = Html::rawElement( 'ul', [], implode( "\n", $errors ) );
1420        } elseif ( $errors instanceof Message ) {
1421            $errors = $errors->parse();
1422        }
1423
1424        return Html::errorBox( $errors );
1425    }
1426
1427    /**
1428     * Turns a *-message parameter (which could be a MessageSpecifier, or a message name, or a
1429     * name + parameters array) into a Message.
1430     * @param mixed $value
1431     * @return Message
1432     */
1433    protected function getMessage( $value ) {
1434        $message = Message::newFromSpecifier( $value );
1435
1436        if ( $this->mParent ) {
1437            $message->setContext( $this->mParent );
1438        }
1439
1440        return $message;
1441    }
1442
1443    /**
1444     * Skip this field when collecting data.
1445     * @stable to override
1446     * @param WebRequest $request
1447     * @return bool
1448     * @since 1.27
1449     */
1450    public function skipLoadData( $request ) {
1451        return !empty( $this->mParams['nodata'] );
1452    }
1453
1454    /**
1455     * Whether this field requires the user agent to have JavaScript enabled for the client-side HTML5
1456     * form validation to work correctly.
1457     *
1458     * @return bool
1459     * @since 1.29
1460     */
1461    public function needsJSForHtml5FormValidation() {
1462        // This is probably more restrictive than it needs to be, but better safe than sorry
1463        return (bool)$this->mCondState;
1464    }
1465}
1466
1467/** @deprecated class alias since 1.42 */
1468class_alias( HTMLFormField::class, 'HTMLFormField' );