@@ -426,7 +426,48 @@ public function sanitize( mixed $input ): array {
426426 // eaten by the save that stores it.
427427 $ set ->save ( wp_slash ( $ input ) );
428428
429- return $ this ->ordered_like_stored ( $ collector ->values () );
429+ return $ this ->ordered_like_stored ( $ this ->with_refused ( $ set , $ collector ->values () ) );
430+ }
431+
432+ /**
433+ * Report each field the set refused, and keep what it held.
434+ *
435+ * The messages go to the Settings API under this page's option name,
436+ * which is where options.php looks once every option is updated: it adds
437+ * "Settings saved." only when nothing has been registered, carries
438+ * whatever has into the transient the next load reads, and
439+ * settings_errors() prints them at the top of the page. A refused email
440+ * address is therefore reported as such rather than announced as saved.
441+ * Guarded, because the Settings API is admin-only and this gate also
442+ * runs for a write from cron or a webhook, where there is no page to
443+ * report to.
444+ *
445+ * The stored value is put back by hand for the write that would
446+ * otherwise lose it. A form save seeds the collector with everything
447+ * stored and the set leaves a failing field alone, so it survives on its
448+ * own; a whole-value write seeds no field at all, and without this the
449+ * value would be dropped — which is the one thing the kit promises a
450+ * refused value is not.
451+ *
452+ * @param FieldSet $set The set that just saved.
453+ * @param array<string, mixed> $values What it collected.
454+ *
455+ * @return array<string, mixed>
456+ */
457+ private function with_refused ( FieldSet $ set , array $ values ): array {
458+ $ stored = $ this ->options ->values ();
459+
460+ foreach ( $ set ->errors () as $ key => $ message ) {
461+ if ( array_key_exists ( $ key , $ stored ) ) {
462+ $ values [ $ key ] = $ stored [ $ key ];
463+ }
464+
465+ if ( function_exists ( 'add_settings_error ' ) ) {
466+ add_settings_error ( (string ) $ this ->config ['option_name ' ], (string ) $ key , $ message , 'error ' );
467+ }
468+ }
469+
470+ return $ values ;
430471 }
431472
432473 /**
@@ -618,7 +659,9 @@ public function render_page(): void {
618659 echo '<div class="wrap field-kit__settings"> ' ;
619660
620661 // No argument: core adds its own "Settings saved." under the `general`
621- // slug, and asking for this page's slug alone would hide it.
662+ // slug, and asking for this page's slug alone would hide it. A field
663+ // the last save refused is printed here as well, and again under the
664+ // field itself.
622665 settings_errors ();
623666
624667 printf ( '<form method="post" action="%s"> ' , esc_url ( admin_url ( 'options.php ' ) ) );
@@ -634,7 +677,7 @@ public function render_page(): void {
634677 esc_attr ( $ tab )
635678 );
636679
637- $ this ->render_sections ( $ tab );
680+ $ this ->render_sections ( $ tab, $ this -> field_errors () );
638681
639682 if ( $ this ->config ['submit_button ' ] ) {
640683 submit_button ();
@@ -643,14 +686,41 @@ public function render_page(): void {
643686 echo '</form></div> ' ;
644687 }
645688
689+ /**
690+ * The messages the last save registered against this page's fields.
691+ *
692+ * Read back from the Settings API rather than from the set that saved,
693+ * because that set belonged to the previous request: options.php saves,
694+ * stores what was registered in a transient and redirects, and
695+ * get_settings_errors() is what reads that transient on the load that
696+ * follows. Only codes that name a field are kept — the import and reset
697+ * notices are registered under the same slug and are not about a field.
698+ *
699+ * @return array<string, string> Keyed by field.
700+ */
701+ private function field_errors (): array {
702+ $ errors = [];
703+
704+ foreach ( (array ) get_settings_errors ( (string ) $ this ->config ['option_name ' ] ) as $ error ) {
705+ $ code = (string ) ( $ error ['code ' ] ?? '' );
706+
707+ if ( 'error ' === (string ) ( $ error ['type ' ] ?? '' ) && isset ( $ this ->fields [ $ code ] ) ) {
708+ $ errors [ $ code ] = (string ) ( $ error ['message ' ] ?? '' );
709+ }
710+ }
711+
712+ return $ errors ;
713+ }
714+
646715 /**
647716 * Render one tab's sections and fields.
648717 *
649- * @param string $tab Tab slug.
718+ * @param string $tab Tab slug.
719+ * @param array<string, string> $errors Messages from the last save, keyed by field.
650720 *
651721 * @return void
652722 */
653- private function render_sections ( string $ tab ): void {
723+ private function render_sections ( string $ tab, array $ errors ): void {
654724 $ set = $ this ->set ( $ tab );
655725 $ fields = $ set ->fields ();
656726 $ shown = [];
@@ -680,15 +750,15 @@ private function render_sections( string $tab ): void {
680750 printf ( '<p class="description">%s</p> ' , wp_kses_post ( (string ) $ section ['description ' ] ) );
681751 }
682752
683- $ this ->render_table ( $ set , $ in_section , (string ) $ slug );
753+ $ this ->render_table ( $ set , $ in_section , (string ) $ slug, $ errors );
684754 }
685755
686756 // A field no section claimed still has to appear, or a typo in a
687757 // section name silently removes it from the page.
688758 $ loose = array_diff_key ( $ fields , array_flip ( $ shown ) );
689759
690760 if ( [] !== $ loose ) {
691- $ this ->render_table ( $ set , $ loose , '' );
761+ $ this ->render_table ( $ set , $ loose , '' , $ errors );
692762 }
693763 }
694764
@@ -699,10 +769,11 @@ private function render_sections( string $tab ): void {
699769 * @param Field[] $fields The fields to render.
700770 * @param string $scope The section these belong to, or empty for the
701771 * fields no section claimed.
772+ * @param array<string, string> $errors Messages from the last save, keyed by field.
702773 *
703774 * @return void
704775 */
705- private function render_table ( FieldSet $ set , array $ fields , string $ scope ): void {
776+ private function render_table ( FieldSet $ set , array $ fields , string $ scope, array $ errors ): void {
706777 $ layout = Sections::split ( $ fields );
707778
708779 if ( [] !== $ layout ) {
@@ -712,13 +783,13 @@ private function render_table( FieldSet $set, array $fields, string $scope ): vo
712783 // first one's panels.
713784 $ sections = Sections::render (
714785 $ layout ,
715- function ( array $ group ) use ( $ set ): string {
786+ function ( array $ group ) use ( $ set, $ errors ): string {
716787 if ( [] === $ group ) {
717788 return '' ;
718789 }
719790
720791 ob_start ();
721- $ this ->render_rows ( $ set , $ group );
792+ $ this ->render_rows ( $ set , $ group, $ errors );
722793
723794 return (string ) ob_get_clean ();
724795 },
@@ -730,18 +801,19 @@ function ( array $group ) use ( $set ): string {
730801 return ;
731802 }
732803
733- $ this ->render_rows ( $ set , $ fields );
804+ $ this ->render_rows ( $ set , $ fields, $ errors );
734805 }
735806
736807 /**
737808 * A set of fields as one settings table.
738809 *
739- * @param FieldSet $set The field set.
740- * @param Field[] $fields The fields to render.
810+ * @param FieldSet $set The field set.
811+ * @param Field[] $fields The fields to render.
812+ * @param array<string, string> $errors Messages from the last save, keyed by field.
741813 *
742814 * @return void
743815 */
744- private function render_rows ( FieldSet $ set , array $ fields ): void {
816+ private function render_rows ( FieldSet $ set , array $ fields, array $ errors ): void {
745817 echo '<table class="form-table" role="presentation"><tbody> ' ;
746818
747819 foreach ( $ fields as $ field ) {
@@ -793,7 +865,7 @@ private function render_rows( FieldSet $set, array $fields ): void {
793865 // The header cell is the visible heading here, so the kit
794866 // draws none: a group keeps its legend, hidden, so the
795867 // grouping is still announced without appearing twice.
796- $ set ->render_field ( $ field , '' , false ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the kit escapes as it builds.
868+ $ set ->render_field ( $ field , $ errors [ $ field -> key () ] ?? '' , false ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the kit escapes as it builds.
797869 );
798870 }
799871
0 commit comments