3 namespace MediaWiki\HTMLForm
;
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
;
18 use Wikimedia\Message\MessageParam
;
19 use Wikimedia\Message\MessageSpecifier
;
22 * The parent class to generate form fields. Any field type should
23 * be a subclass of this.
27 abstract class HTMLFormField
{
28 /** @var array|array[] */
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;
39 /** @var string String label, as HTML. Set on construction. */
44 protected $mClass = '';
46 protected $mVFormClass = '';
47 /** @var string|false */
48 protected $mHelpClass = false;
55 * @var array|null|false
57 protected $mOptions = false;
59 protected $mOptionsLabelsNotFromMessage = false;
61 * @var array Array to hold params for 'hide-if' or 'disable-if' statements
63 protected $mCondState = [];
65 protected $mCondStateClass = [];
68 * @var bool If true will generate an empty div element with no label
71 protected $mShowEmptyLabels = true;
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 );
91 * Same as getInputHTML, but returns an OOUI object.
92 * Defaults to false, which getOOUI will interpret as "use the HTML version"
95 * @param string $value
96 * @return \OOUI\Widget|string|false
98 public function getInputOOUI( $value ) {
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
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()
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
156 public function hasVisibleOutput() {
161 * Get the field name that will be used for submission.
166 public function getName() {
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 );
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
204 * @param array $alldata
205 * @param string $name
206 * @param bool $asDisplay Whether the reverting logic of HTMLCheckField
208 * @param bool $backCompat Whether to try striping the 'wp' prefix.
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 );
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 ) ) {
233 * Fetch a field value from $alldata for the closest field matching a given
236 * @deprecated since 1.38 Use getNearestFieldValue() instead.
237 * @param array $alldata
238 * @param string $name
239 * @param bool $asDisplay
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
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 )
265 if ( count( $params ) !== 1 ) {
266 throw $makeException( "NOT takes exactly one parameter" );
268 // Fall-through intentionally
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 );
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" );
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
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 ];
316 foreach ( $params as $p ) {
317 if ( $valueChk[$op] === $this->checkStateRecurse( $alldata, $p ) ) {
318 return !$valueRet[$op];
321 return $valueRet[$op];
324 return !$this->checkStateRecurse( $alldata, $params[0] );
328 [ $field, $value ] = $params;
329 $testValue = (string)$this->getNearestFieldValue( $alldata, $field, true, true );
332 return ( $value === $testValue );
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
347 protected function parseCondState( $params ) {
348 $op = array_shift( $params );
356 foreach ( $params as $p ) {
357 $ret[] = $this->parseCondState( $p );
362 return [ 'NOT', $this->parseCondState( $params[0] ) ];
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.
377 protected function parseCondStateForClient() {
379 foreach ( $this->mCondState
as $type => $params ) {
380 $parsed[$type] = $this->parseCondState( $params );
386 * Test whether this field is supposed to be hidden, based on the values of
387 * the other form fields.
390 * @param array $alldata The data collected from the form
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.
403 * @param array $alldata The data collected from the form
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
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 ) {
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 ) ) {
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 ) {
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 );
468 * @stable to override
470 * @param mixed $value
471 * @param mixed[] $alldata
475 public function filter( $value, $alldata ) {
476 if ( $this->mFilterCallback
!== null ) {
477 $value = ( $this->mFilterCallback
)( $value, $alldata, $this->mParent
);
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() {
495 * Tell the field whether to generate a separate label element if its label
500 * @param bool $show Set to false to not generate a label.
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
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
);
537 return $this->getDefault();
542 * Initialise the object
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'];
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.
559 __METHOD__
. ": Constructing an HTMLFormField without a 'parent' parameter",
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'] === ' ' ||
$params['label'] === "\u{00A0}" ) {
569 // Apparently some things set   directly and in an odd format
570 $this->mLabel
= "\u{00A0}";
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 = [];
649 if ( !empty( $this->mParams
['vertical-label'] ) ) {
650 $cellAttributes['colspan'] = 2;
651 $verticalLabel = true;
653 $verticalLabel = false;
656 $label = $this->getLabelHtml( $cellAttributes );
658 $field = Html
::rawElement(
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',
677 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
681 $html = Html
::rawElement( 'tr',
683 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
688 return $html . $helptext;
692 * Get the complete div for the input, including help text,
693 * labels, and whatever.
694 * @stable to override
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 );
711 'mw-htmlform-nolabel' => ( $label === '' )
714 $horizontalLabel = $this->mParams
['horizontal-label'] ??
false;
716 if ( $horizontalLabel ) {
717 $field = "\u{00A0}" . $inputHtml . "\n$errors";
719 $field = Html
::rawElement(
721 // @phan-suppress-next-line PhanUselessBinaryAddRight
722 [ 'class' => $outerDivClass ] +
$cellAttributes,
723 $inputHtml . "\n$errors"
727 $wrapperAttributes = [ 'class' => [
728 "mw-htmlform-field-$fieldType",
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 ) .
745 * Get the OOUI version of the div. Falls back to getDiv by default.
746 * @stable to override
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 ) ) ] ),
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 ) ] );
775 $fieldType = $this->getClassName();
776 $help = $this->getHelpText();
777 $errors = $this->getErrorsRaw( $value );
778 foreach ( $errors as &$error ) {
779 $error = new \OOUI\
HtmlSnippet( $error );
783 'classes' => [ "mw-htmlform-field-$fieldType" ],
784 'align' => $this->getLabelAlignOOUI(),
785 'help' => ( $help !== null && $help !== '' ) ?
new \OOUI\
HtmlSnippet( $help ) : null,
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 !== ' ' ) {
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.
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 );
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 !== ' ' ) {
845 $labelFor = $this->needsLabel() ?
[ 'for' => $this->mID
] : [];
846 $labelClasses = [ 'cdx-label' ];
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' ],
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' ],
879 $inputHtml = $this->getInputCodex( $value, $errors !== '' );
880 // <div class="cdx-field__control cdx-field__control--has-help-text">
881 $controlClasses = [ 'cdx-field__control' ];
883 $controlClasses[] = 'cdx-field__control--has-help-text';
885 $control = Html
::rawElement( 'div', [ 'class' => $controlClasses ], $inputHtml );
887 // <div class="cdx-field">
889 "mw-htmlform-field-{$this->getClassName()}",
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
919 protected function getClassName() {
920 $name = explode( '\\', static::class );
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() {
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
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
963 protected function getOOUIModules() {
968 * Get the complete raw fields for the input, including help text,
969 * labels, and whatever.
970 * @stable to override
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
991 * @param string $value The value to set the input to.
992 * @return string Complete HTML field.
994 public function getVForm( $value ) {
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
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() .
1012 $this->getInputHTML( $value ) .
1013 $this->getHelpTextHtmlDiv( $this->getHelpText() );
1017 * Generate help text HTML in table format
1020 * @param string|null $helptext
1023 public function getHelpTextHtmlTable( $helptext ) {
1024 if ( $helptext === null ) {
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
1047 * @param string|null $helptext
1048 * @param string[] $cssClasses
1052 public function getHelpTextHtmlDiv( $helptext, $cssClasses = [] ) {
1053 if ( $helptext === null ) {
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
1074 * @param string|null $helptext
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'] ) ];
1097 * Determine the help text to display
1098 * @stable to override
1100 * @return string|null HTML
1102 public function getHelpText() {
1105 foreach ( $this->getHelpMessages() as $msg ) {
1106 if ( $msg instanceof HtmlArmor
) {
1107 $html[] = HtmlArmor
::getHtml( $msg );
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.
1127 public function isHelpInline() {
1128 return $this->mParams
['help-inline'] ??
true;
1132 * Determine form errors to display and their classes
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() ) {
1150 return [ self
::formatErrors( $errors ), 'mw-htmlform-invalid-input' ];
1154 * Determine form errors to display, returning them in an array.
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() ) {
1167 if ( !is_array( $errors ) ) {
1168 $errors = [ $errors ];
1170 foreach ( $errors as &$error ) {
1171 if ( $error instanceof Message
) {
1172 $error = $error->parse();
1180 * @stable to override
1181 * @return string HTML
1183 public function getLabel() {
1184 return $this->mLabel ??
'';
1188 * @stable to override
1189 * @param array $cellAttributes
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 !== ' ';
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 ) );
1214 return Html
::rawElement( 'label', $for, $labelValue );
1222 * @stable to override
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'] ) ) {
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'] ) ) {
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' ];
1269 foreach ( $list as $key ) {
1270 if ( in_array( $key, $boolAttribs ) ) {
1271 if ( !empty( $this->mParams
[$key] ) ) {
1274 } elseif ( isset( $this->mParams
[$key] ) ) {
1275 $ret[$key] = $this->mParams
[$key];
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
1289 * @return-taint tainted
1291 private function lookupOptionsKeys( $options, $needsParse ) {
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(),
1309 $ret[$msgAsText] = is_array( $value )
1310 ?
$this->lookupOptionsKeys( $value, $needsParse )
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 );
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 );
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 ) {
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 ) {
1384 foreach ( $options as $value ) {
1385 if ( is_array( $value ) ) {
1386 $flatOpts = array_merge( $flatOpts, self
::flattenOptions( $value ) );
1388 $flatOpts[] = $value;
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
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
1433 protected function getMessage( $value ) {
1434 $message = Message
::newFromSpecifier( $value );
1436 if ( $this->mParent
) {
1437 $message->setContext( $this->mParent
);
1444 * Skip this field when collecting data.
1445 * @stable to override
1446 * @param WebRequest $request
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.
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' );