Merge "docs: Fix typo"
[mediawiki.git] / includes / htmlform / HTMLFormField.php
blob4406ff45ad741ae1f01dc6a0ec6a736ea2cea1a9
1 <?php
3 namespace MediaWiki\HTMLForm;
5 use HtmlArmor;
6 use InvalidArgumentException;
7 use MediaWiki\Context\RequestContext;
8 use MediaWiki\Html\Html;
9 use MediaWiki\HTMLForm\Field\HTMLCheckField;
10 use MediaWiki\HTMLForm\Field\HTMLFormFieldCloner;
11 use MediaWiki\Json\FormatJson;
12 use MediaWiki\Linker\Linker;
13 use MediaWiki\Logger\LoggerFactory;
14 use MediaWiki\Message\Message;
15 use MediaWiki\Request\WebRequest;
16 use MediaWiki\Status\Status;
17 use StatusValue;
18 use Wikimedia\Message\MessageParam;
19 use Wikimedia\Message\MessageSpecifier;
21 /**
22 * The parent class to generate form fields. Any field type should
23 * be a subclass of this.
25 * @stable to extend
27 abstract class HTMLFormField {
28 /** @var array|array[] */
29 public $mParams;
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;
54 /**
55 * @var array|null|false
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
63 protected $mCondState = [];
64 /** @var array */
65 protected $mCondStateClass = [];
67 /**
68 * @var bool If true will generate an empty div element with no label
69 * @since 1.22
71 protected $mShowEmptyLabels = true;
73 /**
74 * @var HTMLForm|null
76 public $mParent;
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.
83 * @param mixed $value The value to set the input to; eg a default
84 * text for a text input.
86 * @return string Valid HTML.
88 abstract public function getInputHTML( $value );
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
95 * @param string $value
96 * @return \OOUI\Widget|string|false
98 public function getInputOOUI( $value ) {
99 return false;
103 * Same as getInputHTML, but for Codex. This is called by CodexHTMLForm.
105 * If not overridden, falls back to getInputHTML.
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
113 public function getInputCodex( $value, $hasErrors ) {
114 // If not overridden, fall back to getInputHTML()
115 return $this->getInputHTML( $value );
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
124 public function canDisplayErrors() {
125 return $this->hasVisibleOutput();
129 * Get a translated interface message
131 * This is a wrapper around $this->mParent->msg() if $this->mParent is set
132 * and wfMessage() otherwise.
134 * Parameters are the same as wfMessage().
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
142 public function msg( $key, ...$params ) {
143 if ( $this->mParent ) {
144 return $this->mParent->msg( $key, ...$params );
146 return wfMessage( $key, ...$params );
150 * If this field has a user-visible output or not. If not,
151 * it will not be rendered
152 * @stable to override
154 * @return bool
156 public function hasVisibleOutput() {
157 return true;
161 * Get the field name that will be used for submission.
163 * @since 1.38
164 * @return string
166 public function getName() {
167 return $this->mName;
171 * Get the closest field matching a given name.
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.
177 * @param string $name
178 * @param bool $backCompat Whether to try striping the 'wp' prefix.
179 * @return HTMLFormField
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;
191 if ( $backCompat && str_starts_with( $name, 'wp' ) &&
192 !$this->mParent->hasField( $name )
194 // Don't break the existed use cases.
195 return $this->mParent->getField( substr( $name, 2 ) );
197 return $this->mParent->getField( $name );
201 * Fetch a field value from $alldata for the closest field matching a given
202 * name.
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
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();
224 // Check invert state for HTMLCheckField
225 if ( $asDisplay && $field instanceof HTMLCheckField && ( $field->mParams['invert'] ?? false ) ) {
226 $value = !$value;
229 return $value;
233 * Fetch a field value from $alldata for the closest field matching a given
234 * name.
236 * @deprecated since 1.38 Use getNearestFieldValue() instead.
237 * @param array $alldata
238 * @param string $name
239 * @param bool $asDisplay
240 * @return string
242 protected function getNearestFieldByName( $alldata, $name, $asDisplay = false ) {
243 return (string)$this->getNearestFieldValue( $alldata, $name, $asDisplay );
247 * Validate the cond-state params, the existence check of fields should
248 * be done later.
250 * @param array $params
252 protected function validateCondState( $params ) {
253 $origParams = $params;
254 $op = array_shift( $params );
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 )
263 switch ( $op ) {
264 case 'NOT':
265 if ( count( $params ) !== 1 ) {
266 throw $makeException( "NOT takes exactly one parameter" );
268 // Fall-through intentionally
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" );
279 $this->validateCondState( $p );
281 break;
283 case '===':
284 case '!==':
285 if ( count( $params ) !== 2 ) {
286 throw $makeException( "$op takes exactly two parameters" );
288 [ $name, $value ] = $params;
289 if ( !is_string( $name ) || !is_string( $value ) ) {
290 throw $makeException( "Parameters for $op must be strings" );
292 break;
294 default:
295 throw $makeException( "Unknown operation" );
300 * Helper function for isHidden and isDisabled to handle recursive data structures.
302 * @param array $alldata
303 * @param array $params
304 * @return bool
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 ];
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];
321 return $valueRet[$op];
323 case 'NOT':
324 return !$this->checkStateRecurse( $alldata, $params[0] );
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 );
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.
344 * @param array $params
345 * @return mixed[]
347 protected function parseCondState( $params ) {
348 $op = array_shift( $params );
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 );
359 return $ret;
361 case 'NOT':
362 return [ 'NOT', $this->parseCondState( $params[0] ) ];
364 case '===':
365 case '!==':
366 [ $name, $value ] = $params;
367 $field = $this->getNearestField( $name, true );
368 return [ $op, $field->getName(), $value ];
373 * Parse the cond-state array for client-side.
375 * @return array[]
377 protected function parseCondStateForClient() {
378 $parsed = [];
379 foreach ( $this->mCondState as $type => $params ) {
380 $parsed[$type] = $this->parseCondState( $params );
382 return $parsed;
386 * Test whether this field is supposed to be hidden, based on the values of
387 * the other form fields.
389 * @since 1.23
390 * @param array $alldata The data collected from the form
391 * @return bool
393 public function isHidden( $alldata ) {
394 return isset( $this->mCondState['hide'] ) &&
395 $this->checkStateRecurse( $alldata, $this->mCondState['hide'] );
399 * Test whether this field is supposed to be disabled, based on the values of
400 * the other form fields.
402 * @since 1.38
403 * @param array $alldata The data collected from the form
404 * @return bool
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'] ) );
414 * Override this function if the control can somehow trigger a form
415 * submission that shouldn't actually submit the HTMLForm.
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
422 * @return bool True to cancel the submission
424 public function cancelSubmit( $value, $alldata ) {
425 return false;
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
434 * @param mixed $value The value the field was submitted with
435 * @param array $alldata The data collected from the form
437 * @return bool|string|Message True on success, or String/Message error to display, or
438 * false to fail validation without displaying an error.
440 public function validate( $value, $alldata ) {
441 if ( $this->isHidden( $alldata ) ) {
442 return true;
445 if ( isset( $this->mParams['required'] )
446 && $this->mParams['required'] !== false
447 && ( $value === '' || $value === false || $value === null )
449 return $this->msg( 'htmlform-required' );
452 if ( $this->mValidationCallback === null ) {
453 return true;
456 $p = ( $this->mValidationCallback )( $value, $alldata, $this->mParent );
458 if ( $p instanceof StatusValue ) {
459 $language = $this->mParent ? $this->mParent->getLanguage() : RequestContext::getMain()->getLanguage();
461 return $p->isGood() ? true : Status::wrap( $p )->getHTML( false, false, $language );
464 return $p;
468 * @stable to override
470 * @param mixed $value
471 * @param mixed[] $alldata
473 * @return mixed
475 public function filter( $value, $alldata ) {
476 if ( $this->mFilterCallback !== null ) {
477 $value = ( $this->mFilterCallback )( $value, $alldata, $this->mParent );
480 return $value;
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
488 * @return bool True to output a label, false to suppress
490 protected function needsLabel() {
491 return true;
495 * Tell the field whether to generate a separate label element if its label
496 * is blank.
498 * @since 1.22
500 * @param bool $show Set to false to not generate a label.
501 * @return void
503 public function setShowEmptyLabel( $show ) {
504 $this->mShowEmptyLabels = $show;
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.
511 * Returns true if the request was posted and has a field for a CSRF token (wpEditToken), or
512 * has a form identifier (wpFormIdentifier).
514 * @todo Consider moving this to HTMLForm?
515 * @param WebRequest $request
516 * @return bool
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' );
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
530 * @param WebRequest $request
531 * @return mixed The value
533 public function loadDataFromRequest( $request ) {
534 if ( $request->getCheck( $this->mName ) ) {
535 return $request->getText( $this->mName );
536 } else {
537 return $this->getDefault();
542 * Initialise the object
544 * @stable to call
545 * @param array $params Associative Array. See HTMLForm doc for syntax.
547 * @since 1.22 The 'label' attribute no longer accepts raw HTML, use 'label-raw' instead
549 public function __construct( $params ) {
550 $this->mParams = $params;
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"
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'] );
574 } elseif ( isset( $params['label-raw'] ) ) {
575 $this->mLabel = $params['label-raw'];
578 $this->mName = $params['name'] ?? 'wp' . $params['fieldname'];
580 if ( isset( $params['dir'] ) ) {
581 $this->mDir = $params['dir'];
584 $this->mID = "mw-input-{$this->mName}";
586 if ( isset( $params['default'] ) ) {
587 $this->mDefault = $params['default'];
590 if ( isset( $params['id'] ) ) {
591 $this->mID = $params['id'];
594 if ( isset( $params['cssclass'] ) ) {
595 $this->mClass = $params['cssclass'];
598 if ( isset( $params['csshelpclass'] ) ) {
599 $this->mHelpClass = $params['csshelpclass'];
602 if ( isset( $params['validation-callback'] ) ) {
603 $this->mValidationCallback = $params['validation-callback'];
606 if ( isset( $params['filter-callback'] ) ) {
607 $this->mFilterCallback = $params['filter-callback'];
610 if ( isset( $params['hidelabel'] ) ) {
611 $this->mShowEmptyLabels = false;
613 if ( isset( $params['notices'] ) ) {
614 $this->mNotices = $params['notices'];
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';
622 if ( !( isset( $params['disabled'] ) && $params['disabled'] ) &&
623 isset( $params['disable-if'] ) && $params['disable-if']
625 $this->validateCondState( $params['disable-if'] );
626 $this->mCondState['disable'] = $params['disable-if'];
627 $this->mCondStateClass[] = 'mw-htmlform-disable-if';
632 * Get the complete table row for the input, including help text,
633 * labels, and whatever.
634 * @stable to override
636 * @param string $value The value to set the input to.
638 * @return string Complete HTML table row.
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 = '';
649 if ( !empty( $this->mParams['vertical-label'] ) ) {
650 $cellAttributes['colspan'] = 2;
651 $verticalLabel = true;
652 } else {
653 $verticalLabel = false;
656 $label = $this->getLabelHtml( $cellAttributes );
658 $field = Html::rawElement(
659 'td',
660 [ 'class' => 'mw-input' ] + $cellAttributes,
661 $inputHtml . "\n$errors"
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';
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"
679 $field );
680 } else {
681 $html = Html::rawElement( 'tr',
682 $rowAttributes + [
683 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
685 $label . $field );
688 return $html . $helptext;
692 * Get the complete div for the input, including help text,
693 * labels, and whatever.
694 * @stable to override
695 * @since 1.20
697 * @param string $value The value to set the input to.
699 * @return string Complete HTML table row.
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 );
709 $outerDivClass = [
710 'mw-input',
711 'mw-htmlform-nolabel' => ( $label === '' )
714 $horizontalLabel = $this->mParams['horizontal-label'] ?? false;
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"
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';
740 return Html::rawElement( 'div', $wrapperAttributes, $label . $field ) .
741 $helptext;
745 * Get the OOUI version of the div. Falls back to getDiv by default.
746 * @stable to override
747 * @since 1.26
749 * @param string $value The value to set the input to.
751 * @return \OOUI\FieldLayout
753 public function getOOUI( $value ) {
754 $inputField = $this->getInputOOUI( $value );
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' ]
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;
775 $fieldType = $this->getClassName();
776 $help = $this->getHelpText();
777 $errors = $this->getErrorsRaw( $value );
778 foreach ( $errors as &$error ) {
779 $error = new \OOUI\HtmlSnippet( $error );
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 ?: [],
791 if ( $this->mClass !== '' ) {
792 $config['classes'][] = $this->mClass;
795 $preloadModules = false;
797 if ( $infusable && $this->shouldInfuseOOUI() ) {
798 $preloadModules = true;
799 $config['classes'][] = 'mw-htmlform-autoinfuse';
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';
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 );
814 if ( $this->mCondState ) {
815 $preloadModules = true;
816 $config['condState'] = $this->parseCondStateForClient();
819 $config['modules'] = $this->getOOUIModules();
821 if ( $preloadModules ) {
822 $this->mParent->getOutput()->addModules( 'mediawiki.htmlform.ooui' );
823 $this->mParent->getOutput()->addModules( $this->getOOUIModules() );
826 return $this->getFieldLayoutOOUI( $inputField, $config );
830 * Get the Codex version of the div.
831 * @since 1.42
833 * @param string $value The value to set the input to.
834 * @return string HTML
836 public function getCodex( $value ) {
837 $isDisabled = ( $this->mParams['disabled'] ?? false );
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';
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
862 // Help text
863 // <div class="cdx-field__help-text">
864 $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText(), [ 'cdx-field__help-text' ] );
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
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';
885 $control = Html::rawElement( 'div', [ 'class' => $controlClasses ], $inputHtml );
887 // <div class="cdx-field">
888 $fieldClasses = [
889 "mw-htmlform-field-{$this->getClassName()}",
890 $this->mClass,
891 $errorClass,
892 'cdx-field'
894 if ( $isDisabled ) {
895 $fieldClasses[] = 'cdx-field--disabled';
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';
907 return Html::rawElement( 'div', [ 'class' => $fieldClasses ] + $fieldAttributes,
908 $labelDiv . $control . $helptext . $validationMessage
913 * Gets the non namespaced class name
915 * @since 1.36
917 * @return string
919 protected function getClassName() {
920 $name = explode( '\\', static::class );
921 return end( $name );
925 * Get label alignment when generating field for OOUI.
926 * @stable to override
927 * @return string 'left', 'right', 'top' or 'inline'
929 protected function getLabelAlignOOUI() {
930 return 'top';
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
939 protected function getFieldLayoutOOUI( $inputField, $config ) {
940 return new HTMLFormFieldLayout( $inputField, $config );
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
949 * @return bool
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();
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
961 * @return string[]
963 protected function getOOUIModules() {
964 return [];
968 * Get the complete raw fields for the input, including help text,
969 * labels, and whatever.
970 * @stable to override
971 * @since 1.20
973 * @param string $value The value to set the input to.
975 * @return string Complete HTML table row.
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() );
986 * Get the complete field for the input, including help text,
987 * labels, and whatever. Fall back from 'vform' to 'div' when not overridden.
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.
994 public function getVForm( $value ) {
995 // Ewwww
996 $this->mVFormClass = ' mw-ui-vform-field';
997 return $this->getDiv( $value );
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
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() );
1017 * Generate help text HTML in table format
1018 * @since 1.20
1020 * @param string|null $helptext
1021 * @return string
1023 public function getHelpTextHtmlTable( $helptext ) {
1024 if ( $helptext === null ) {
1025 return '';
1028 $rowAttributes = [];
1029 if ( $this->mCondState ) {
1030 $rowAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
1031 $rowAttributes['class'] = $this->mCondStateClass;
1034 $tdClasses = [ 'htmlform-tip' ];
1035 if ( $this->mHelpClass !== false ) {
1036 $tdClasses[] = $this->mHelpClass;
1038 return Html::rawElement( 'tr', $rowAttributes,
1039 Html::rawElement( 'td', [ 'colspan' => 2, 'class' => $tdClasses ], $helptext )
1044 * Generate help text HTML in div format
1045 * @since 1.20
1047 * @param string|null $helptext
1048 * @param string[] $cssClasses
1050 * @return string
1052 public function getHelpTextHtmlDiv( $helptext, $cssClasses = [] ) {
1053 if ( $helptext === null ) {
1054 return '';
1057 $wrapperAttributes = [
1058 'class' => array_merge( $cssClasses, [ 'htmlform-tip' ] ),
1060 if ( $this->mHelpClass !== false ) {
1061 $wrapperAttributes['class'][] = $this->mHelpClass;
1063 if ( $this->mCondState ) {
1064 $wrapperAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
1065 $wrapperAttributes['class'] = array_merge( $wrapperAttributes['class'], $this->mCondStateClass );
1067 return Html::rawElement( 'div', $wrapperAttributes, $helptext );
1071 * Generate help text HTML formatted for raw output
1072 * @since 1.20
1074 * @param string|null $helptext
1075 * @return string
1077 public function getHelpTextHtmlRaw( $helptext ) {
1078 return $this->getHelpTextHtmlDiv( $helptext );
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'] ) ];
1093 return [];
1097 * Determine the help text to display
1098 * @stable to override
1099 * @since 1.20
1100 * @return string|null HTML
1102 public function getHelpText() {
1103 $html = [];
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();
1116 return $html ? implode( $this->msg( 'word-separator' )->escaped(), $html ) : null;
1120 * Determine if the help text should be displayed inline.
1122 * Only applies to OOUI forms.
1124 * @since 1.31
1125 * @return bool
1127 public function isHelpInline() {
1128 return $this->mParams['help-inline'] ?? true;
1132 * Determine form errors to display and their classes
1133 * @since 1.20
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.
1139 * @param string $value The value of the input
1140 * @return array [ $errors, $errorClass ]
1141 * @return-taint none
1143 public function getErrorsAndErrorClass( $value ) {
1144 $errors = $this->validate( $value, $this->mParent->mFieldData );
1146 if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
1147 return [ '', '' ];
1150 return [ self::formatErrors( $errors ), 'mw-htmlform-invalid-input' ];
1154 * Determine form errors to display, returning them in an array.
1156 * @since 1.26
1157 * @param string $value The value of the input
1158 * @return string[] Array of error HTML strings
1160 public function getErrorsRaw( $value ) {
1161 $errors = $this->validate( $value, $this->mParent->mFieldData );
1163 if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
1164 return [];
1167 if ( !is_array( $errors ) ) {
1168 $errors = [ $errors ];
1170 foreach ( $errors as &$error ) {
1171 if ( $error instanceof Message ) {
1172 $error = $error->parse();
1176 return $errors;
1180 * @stable to override
1181 * @return string HTML
1183 public function getLabel() {
1184 return $this->mLabel ?? '';
1188 * @stable to override
1189 * @param array $cellAttributes
1191 * @return string
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 ] : [];
1198 $labelValue = trim( $this->getLabel() );
1199 $hasLabel = $labelValue !== '' && $labelValue !== "\u{00A0}" && $labelValue !== '&#160;';
1201 $displayFormat = $this->mParent->getDisplayFormat();
1202 $horizontalLabel = $this->mParams['horizontal-label'] ?? false;
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 );
1218 return '';
1222 * @stable to override
1223 * @return mixed
1225 public function getDefault() {
1226 return $this->mDefault ?? null;
1230 * Returns the attributes required for the tooltip and accesskey, for Html::element() etc.
1232 * @return array Attributes
1234 public function getTooltipAndAccessKey() {
1235 if ( empty( $this->mParams['tooltip'] ) ) {
1236 return [];
1239 return Linker::tooltipAndAccesskeyAttribs( $this->mParams['tooltip'] );
1243 * Returns the attributes required for the tooltip and accesskey, for OOUI widgets' config.
1245 * @return array Attributes
1247 public function getTooltipAndAccessKeyOOUI() {
1248 if ( empty( $this->mParams['tooltip'] ) ) {
1249 return [];
1252 return [
1253 'title' => Linker::titleAttrib( $this->mParams['tooltip'] ),
1254 'accessKey' => Linker::accesskey( $this->mParams['tooltip'] ),
1259 * Returns the given attributes from the parameters
1260 * @stable to override
1262 * @param array $list List of attributes to get
1263 * @return array Attributes
1265 public function getAttributes( array $list ) {
1266 static $boolAttribs = [ 'disabled', 'required', 'autofocus', 'multiple', 'readonly' ];
1268 $ret = [];
1269 foreach ( $list as $key ) {
1270 if ( in_array( $key, $boolAttribs ) ) {
1271 if ( !empty( $this->mParams[$key] ) ) {
1272 $ret[$key] = '';
1274 } elseif ( isset( $this->mParams[$key] ) ) {
1275 $ret[$key] = $this->mParams[$key];
1279 return $ret;
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.
1286 * @param array $options
1287 * @param bool $needsParse
1288 * @return array
1289 * @return-taint tainted
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.',
1301 'msg_key_one' => $key,
1302 'lang' => $this->mParent ?
1303 $this->mParent->getLanguageCode()->toBcp47Code() :
1304 RequestContext::getMain()->getLanguageCode()->toBcp47Code(),
1307 continue;
1309 $ret[$msgAsText] = is_array( $value )
1310 ? $this->lookupOptionsKeys( $value, $needsParse )
1311 : strval( $value );
1313 return $ret;
1317 * Recursively forces values in an array to strings, because issues arise
1318 * with integer 0 as a value.
1320 * @param array|string $array
1321 * @return array|string
1323 public static function forceToStringRecursive( $array ) {
1324 if ( is_array( $array ) ) {
1325 return array_map( [ __CLASS__, 'forceToStringRecursive' ], $array );
1326 } else {
1327 return strval( $array );
1332 * Fetch the array of options from the field's parameters. In order, this
1333 * checks 'options-messages', 'options', then 'options-message'.
1335 * @return array|null
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;
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;
1356 return $this->mOptions;
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.
1364 public function getOptionsOOUI() {
1365 $oldoptions = $this->getOptions();
1367 if ( $oldoptions === null ) {
1368 return null;
1371 return Html::listDropdownOptionsOoui( $oldoptions );
1375 * flatten an array of options to a single array, for instance,
1376 * a set of "<options>" inside "<optgroups>".
1378 * @param array $options Associative Array with values either Strings or Arrays
1379 * @return array Flattened input
1381 public static function flattenOptions( $options ) {
1382 $flatOpts = [];
1384 foreach ( $options as $value ) {
1385 if ( is_array( $value ) ) {
1386 $flatOpts = array_merge( $flatOpts, self::flattenOptions( $value ) );
1387 } else {
1388 $flatOpts[] = $value;
1392 return $flatOpts;
1396 * Formats one or more errors as accepted by field validation-callback.
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
1408 protected static function formatErrors( $errors ) {
1409 if ( is_array( $errors ) && count( $errors ) === 1 ) {
1410 $errors = array_shift( $errors );
1413 if ( is_array( $errors ) ) {
1414 foreach ( $errors as &$error ) {
1415 $error = Html::rawElement( 'li', [],
1416 $error instanceof Message ? $error->parse() : $error
1419 $errors = Html::rawElement( 'ul', [], implode( "\n", $errors ) );
1420 } elseif ( $errors instanceof Message ) {
1421 $errors = $errors->parse();
1424 return Html::errorBox( $errors );
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
1433 protected function getMessage( $value ) {
1434 $message = Message::newFromSpecifier( $value );
1436 if ( $this->mParent ) {
1437 $message->setContext( $this->mParent );
1440 return $message;
1444 * Skip this field when collecting data.
1445 * @stable to override
1446 * @param WebRequest $request
1447 * @return bool
1448 * @since 1.27
1450 public function skipLoadData( $request ) {
1451 return !empty( $this->mParams['nodata'] );
1455 * Whether this field requires the user agent to have JavaScript enabled for the client-side HTML5
1456 * form validation to work correctly.
1458 * @return bool
1459 * @since 1.29
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;
1467 /** @deprecated class alias since 1.42 */
1468 class_alias( HTMLFormField::class, 'HTMLFormField' );