medical-toc-widget.php

I’ll build this as a strong standalone WordPress widget file for inc/widgets/medical-toc-widget.php, with admin options, auto heading detection, smooth scroll, schema-friendly output, accessibility, caching, styling, and shortcode support.

Below is an advanced complete file for your RX Theme:

inc/widgets/medical-toc-widget.php

It adds a powerful Medical Table of Contents Widget with:

  • Auto TOC from post headings
  • H2/H3/H4/H5/H6 support
  • Sticky TOC option
  • Collapsible TOC
  • Smooth scroll
  • Active section highlight
  • Reading progress bar
  • Copy section link button
  • Estimated reading time
  • Word count
  • Medical article summary box
  • Schema-friendly navigation
  • Mobile-friendly layout
  • Widget settings
  • Shortcode support: [rx_medical_toc]
  • Elementor/sidebar compatible
  • Safe escaping and sanitization
  • Lightweight inline CSS/JS
  • Cache support with post modified time
  • Exclude headings by text/class
  • Numbered or plain TOC
  • Scroll offset control for sticky headers
<?php
/**
 * RX Theme - Advanced Medical Table of Contents Widget
 *
 * File: inc/widgets/medical-toc-widget.php
 *
 * @package RX_Theme
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

if ( ! class_exists( 'RX_Medical_TOC_Widget' ) ) :

class RX_Medical_TOC_Widget extends WP_Widget {

	/**
	 * Widget defaults.
	 */
	protected $defaults = array(
		'title'                  => 'Article Contents',
		'intro_text'             => 'Quickly jump to the important medical sections of this article.',
		'show_on_post_types'     => 'post,page',
		'heading_levels'         => 'h2,h3,h4',
		'min_headings'           => 3,
		'exclude_headings'       => '',
		'exclude_classes'        => 'no-toc,rx-no-toc,screen-reader-text',
		'numbered'               => 1,
		'nested'                 => 1,
		'collapsible'            => 1,
		'collapsed_default'      => 0,
		'sticky'                 => 1,
		'smooth_scroll'          => 1,
		'active_highlight'       => 1,
		'show_reading_time'      => 1,
		'show_word_count'        => 1,
		'show_progress'          => 1,
		'show_copy_links'        => 1,
		'show_summary'           => 1,
		'scroll_offset'          => 90,
		'max_width'              => '',
		'accent_color'           => '#0b7d77',
		'background_color'       => '#ffffff',
		'border_color'           => '#dfe9e7',
		'text_color'             => '#1f2937',
		'cache_enabled'          => 1,
		'custom_class'           => '',
	);

	/**
	 * Constructor.
	 */
	public function __construct() {
		parent::__construct(
			'rx_medical_toc_widget',
			esc_html__( 'RX Medical Table of Contents', 'rx-theme' ),
			array(
				'classname'                   => 'rx_medical_toc_widget',
				'description'                 => esc_html__( 'Advanced medical article table of contents with sticky, collapsible, progress, reading time, and active heading highlight.', 'rx-theme' ),
				'customize_selective_refresh' => true,
			)
		);

		add_filter( 'the_content', array( $this, 'inject_heading_ids_into_content' ), 20 );
		add_shortcode( 'rx_medical_toc', array( $this, 'shortcode_output' ) );
		add_action( 'wp_enqueue_scripts', array( $this, 'register_assets' ) );
	}

	/**
	 * Register empty handles for inline CSS/JS.
	 */
	public function register_assets() {
		wp_register_style( 'rx-medical-toc-widget', false, array(), '1.0.0' );
		wp_register_script( 'rx-medical-toc-widget', false, array(), '1.0.0', true );
	}

	/**
	 * Widget frontend output.
	 */
	public function widget( $args, $instance ) {
		if ( ! is_singular() ) {
			return;
		}

		$instance = wp_parse_args( (array) $instance, $this->defaults );

		if ( ! $this->is_allowed_post_type( $instance ) ) {
			return;
		}

		$output = $this->build_toc_output( $instance, 'widget' );

		if ( empty( $output ) ) {
			return;
		}

		echo isset( $args['before_widget'] ) ? $args['before_widget'] : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo isset( $args['after_widget'] ) ? $args['after_widget'] : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Widget backend form.
	 */
	public function form( $instance ) {
		$instance = wp_parse_args( (array) $instance, $this->defaults );
		?>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
				<?php esc_html_e( 'Title:', 'rx-theme' ); ?>
			</label>
			<input class="widefat"
				id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
				type="text"
				value="<?php echo esc_attr( $instance['title'] ); ?>">
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'intro_text' ) ); ?>">
				<?php esc_html_e( 'Intro Text:', 'rx-theme' ); ?>
			</label>
			<textarea class="widefat"
				id="<?php echo esc_attr( $this->get_field_id( 'intro_text' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'intro_text' ) ); ?>"
				rows="3"><?php echo esc_textarea( $instance['intro_text'] ); ?></textarea>
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'show_on_post_types' ) ); ?>">
				<?php esc_html_e( 'Show on post types:', 'rx-theme' ); ?>
			</label>
			<input class="widefat"
				id="<?php echo esc_attr( $this->get_field_id( 'show_on_post_types' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'show_on_post_types' ) ); ?>"
				type="text"
				value="<?php echo esc_attr( $instance['show_on_post_types'] ); ?>">
			<small><?php esc_html_e( 'Comma separated. Example: post,page,rx_medical', 'rx-theme' ); ?></small>
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'heading_levels' ) ); ?>">
				<?php esc_html_e( 'Heading levels:', 'rx-theme' ); ?>
			</label>
			<input class="widefat"
				id="<?php echo esc_attr( $this->get_field_id( 'heading_levels' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'heading_levels' ) ); ?>"
				type="text"
				value="<?php echo esc_attr( $instance['heading_levels'] ); ?>">
			<small><?php esc_html_e( 'Example: h2,h3,h4', 'rx-theme' ); ?></small>
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'min_headings' ) ); ?>">
				<?php esc_html_e( 'Minimum headings required:', 'rx-theme' ); ?>
			</label>
			<input class="small-text"
				id="<?php echo esc_attr( $this->get_field_id( 'min_headings' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'min_headings' ) ); ?>"
				type="number"
				min="1"
				value="<?php echo esc_attr( absint( $instance['min_headings'] ) ); ?>">
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'exclude_headings' ) ); ?>">
				<?php esc_html_e( 'Exclude headings containing:', 'rx-theme' ); ?>
			</label>
			<textarea class="widefat"
				id="<?php echo esc_attr( $this->get_field_id( 'exclude_headings' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'exclude_headings' ) ); ?>"
				rows="3"><?php echo esc_textarea( $instance['exclude_headings'] ); ?></textarea>
			<small><?php esc_html_e( 'Comma separated words or phrases. Example: References, Disclaimer, Related Posts', 'rx-theme' ); ?></small>
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'exclude_classes' ) ); ?>">
				<?php esc_html_e( 'Exclude heading classes:', 'rx-theme' ); ?>
			</label>
			<input class="widefat"
				id="<?php echo esc_attr( $this->get_field_id( 'exclude_classes' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'exclude_classes' ) ); ?>"
				type="text"
				value="<?php echo esc_attr( $instance['exclude_classes'] ); ?>">
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'scroll_offset' ) ); ?>">
				<?php esc_html_e( 'Scroll offset in px:', 'rx-theme' ); ?>
			</label>
			<input class="small-text"
				id="<?php echo esc_attr( $this->get_field_id( 'scroll_offset' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'scroll_offset' ) ); ?>"
				type="number"
				min="0"
				value="<?php echo esc_attr( absint( $instance['scroll_offset'] ) ); ?>">
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'accent_color' ) ); ?>">
				<?php esc_html_e( 'Accent color:', 'rx-theme' ); ?>
			</label>
			<input class="widefat"
				id="<?php echo esc_attr( $this->get_field_id( 'accent_color' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'accent_color' ) ); ?>"
				type="text"
				value="<?php echo esc_attr( $instance['accent_color'] ); ?>">
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'background_color' ) ); ?>">
				<?php esc_html_e( 'Background color:', 'rx-theme' ); ?>
			</label>
			<input class="widefat"
				id="<?php echo esc_attr( $this->get_field_id( 'background_color' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'background_color' ) ); ?>"
				type="text"
				value="<?php echo esc_attr( $instance['background_color'] ); ?>">
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'border_color' ) ); ?>">
				<?php esc_html_e( 'Border color:', 'rx-theme' ); ?>
			</label>
			<input class="widefat"
				id="<?php echo esc_attr( $this->get_field_id( 'border_color' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'border_color' ) ); ?>"
				type="text"
				value="<?php echo esc_attr( $instance['border_color'] ); ?>">
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'text_color' ) ); ?>">
				<?php esc_html_e( 'Text color:', 'rx-theme' ); ?>
			</label>
			<input class="widefat"
				id="<?php echo esc_attr( $this->get_field_id( 'text_color' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'text_color' ) ); ?>"
				type="text"
				value="<?php echo esc_attr( $instance['text_color'] ); ?>">
		</p>

		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'custom_class' ) ); ?>">
				<?php esc_html_e( 'Custom CSS class:', 'rx-theme' ); ?>
			</label>
			<input class="widefat"
				id="<?php echo esc_attr( $this->get_field_id( 'custom_class' ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( 'custom_class' ) ); ?>"
				type="text"
				value="<?php echo esc_attr( $instance['custom_class'] ); ?>">
		</p>

		<hr>

		<?php
		$this->checkbox_field( $instance, 'numbered', __( 'Show numbered TOC', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'nested', __( 'Use nested heading structure', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'collapsible', __( 'Enable collapse button', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'collapsed_default', __( 'Collapsed by default', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'sticky', __( 'Sticky TOC on desktop', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'smooth_scroll', __( 'Enable smooth scroll', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'active_highlight', __( 'Highlight active section', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'show_reading_time', __( 'Show reading time', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'show_word_count', __( 'Show word count', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'show_progress', __( 'Show reading progress bar', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'show_copy_links', __( 'Show copy section link buttons', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'show_summary', __( 'Show medical summary box', 'rx-theme' ) );
		$this->checkbox_field( $instance, 'cache_enabled', __( 'Enable TOC cache', 'rx-theme' ) );
	}

	/**
	 * Checkbox helper.
	 */
	protected function checkbox_field( $instance, $key, $label ) {
		?>
		<p>
			<input class="checkbox"
				type="checkbox"
				<?php checked( ! empty( $instance[ $key ] ) ); ?>
				id="<?php echo esc_attr( $this->get_field_id( $key ) ); ?>"
				name="<?php echo esc_attr( $this->get_field_name( $key ) ); ?>"
				value="1">
			<label for="<?php echo esc_attr( $this->get_field_id( $key ) ); ?>">
				<?php echo esc_html( $label ); ?>
			</label>
		</p>
		<?php
	}

	/**
	 * Save widget options.
	 */
	public function update( $new_instance, $old_instance ) {
		$instance = array();

		$text_fields = array(
			'title',
			'intro_text',
			'show_on_post_types',
			'heading_levels',
			'exclude_headings',
			'exclude_classes',
			'custom_class',
		);

		foreach ( $text_fields as $field ) {
			$instance[ $field ] = isset( $new_instance[ $field ] ) ? sanitize_text_field( $new_instance[ $field ] ) : $this->defaults[ $field ];
		}

		$color_fields = array(
			'accent_color',
			'background_color',
			'border_color',
			'text_color',
		);

		foreach ( $color_fields as $field ) {
			$instance[ $field ] = isset( $new_instance[ $field ] ) ? sanitize_hex_color( $new_instance[ $field ] ) : $this->defaults[ $field ];
			if ( empty( $instance[ $field ] ) ) {
				$instance[ $field ] = $this->defaults[ $field ];
			}
		}

		$instance['min_headings']  = isset( $new_instance['min_headings'] ) ? max( 1, absint( $new_instance['min_headings'] ) ) : 3;
		$instance['scroll_offset'] = isset( $new_instance['scroll_offset'] ) ? max( 0, absint( $new_instance['scroll_offset'] ) ) : 90;

		$checkboxes = array(
			'numbered',
			'nested',
			'collapsible',
			'collapsed_default',
			'sticky',
			'smooth_scroll',
			'active_highlight',
			'show_reading_time',
			'show_word_count',
			'show_progress',
			'show_copy_links',
			'show_summary',
			'cache_enabled',
		);

		foreach ( $checkboxes as $field ) {
			$instance[ $field ] = ! empty( $new_instance[ $field ] ) ? 1 : 0;
		}

		$this->clear_post_cache();

		return $instance;
	}

	/**
	 * Shortcode output.
	 *
	 * Usage:
	 * [rx_medical_toc]
	 * [rx_medical_toc title="Contents" levels="h2,h3" sticky="0"]
	 */
	public function shortcode_output( $atts ) {
		if ( ! is_singular() ) {
			return '';
		}

		$atts = shortcode_atts(
			array(
				'title'             => $this->defaults['title'],
				'intro'             => $this->defaults['intro_text'],
				'levels'            => $this->defaults['heading_levels'],
				'min'               => $this->defaults['min_headings'],
				'numbered'          => $this->defaults['numbered'],
				'nested'            => $this->defaults['nested'],
				'collapsible'       => $this->defaults['collapsible'],
				'collapsed'         => $this->defaults['collapsed_default'],
				'sticky'            => 0,
				'reading_time'      => $this->defaults['show_reading_time'],
				'word_count'        => $this->defaults['show_word_count'],
				'progress'          => $this->defaults['show_progress'],
				'copy_links'        => $this->defaults['show_copy_links'],
				'summary'           => $this->defaults['show_summary'],
				'offset'            => $this->defaults['scroll_offset'],
				'accent'            => $this->defaults['accent_color'],
				'background'        => $this->defaults['background_color'],
				'border'            => $this->defaults['border_color'],
				'text'              => $this->defaults['text_color'],
				'class'             => '',
			),
			$atts,
			'rx_medical_toc'
		);

		$instance = wp_parse_args(
			array(
				'title'              => sanitize_text_field( $atts['title'] ),
				'intro_text'         => sanitize_text_field( $atts['intro'] ),
				'heading_levels'     => sanitize_text_field( $atts['levels'] ),
				'min_headings'       => absint( $atts['min'] ),
				'numbered'           => absint( $atts['numbered'] ),
				'nested'             => absint( $atts['nested'] ),
				'collapsible'        => absint( $atts['collapsible'] ),
				'collapsed_default'  => absint( $atts['collapsed'] ),
				'sticky'             => absint( $atts['sticky'] ),
				'show_reading_time'  => absint( $atts['reading_time'] ),
				'show_word_count'    => absint( $atts['word_count'] ),
				'show_progress'      => absint( $atts['progress'] ),
				'show_copy_links'    => absint( $atts['copy_links'] ),
				'show_summary'       => absint( $atts['summary'] ),
				'scroll_offset'      => absint( $atts['offset'] ),
				'accent_color'       => sanitize_hex_color( $atts['accent'] ),
				'background_color'   => sanitize_hex_color( $atts['background'] ),
				'border_color'       => sanitize_hex_color( $atts['border'] ),
				'text_color'         => sanitize_hex_color( $atts['text'] ),
				'custom_class'       => sanitize_html_class( $atts['class'] ),
				'cache_enabled'      => 1,
			),
			$this->defaults
		);

		return $this->build_toc_output( $instance, 'shortcode' );
	}

	/**
	 * Check allowed post type.
	 */
	protected function is_allowed_post_type( $instance ) {
		$post_type = get_post_type();

		if ( ! $post_type ) {
			return false;
		}

		$allowed = $this->csv_to_array( $instance['show_on_post_types'] );

		return in_array( $post_type, $allowed, true );
	}

	/**
	 * Build TOC HTML.
	 */
	protected function build_toc_output( $instance, $context = 'widget' ) {
		global $post;

		if ( empty( $post ) || empty( $post->post_content ) ) {
			return '';
		}

		$instance = wp_parse_args( (array) $instance, $this->defaults );

		$cache_key = 'rx_medical_toc_' . $post->ID . '_' . md5( wp_json_encode( $instance ) . get_post_modified_time( 'U', true, $post ) );

		if ( ! empty( $instance['cache_enabled'] ) ) {
			$cached = get_transient( $cache_key );
			if ( false !== $cached ) {
				$this->enqueue_inline_assets( $instance );
				return $cached;
			}
		}

		$headings = $this->extract_headings( $post->post_content, $instance );

		if ( count( $headings ) < absint( $instance['min_headings'] ) ) {
			return '';
		}

		$reading_time = $this->get_reading_time( $post->post_content );
		$word_count   = $this->get_word_count( $post->post_content );
		$uid          = 'rx-medical-toc-' . absint( $post->ID ) . '-' . wp_rand( 1000, 9999 );

		$classes = array(
			'rx-medical-toc',
			'rx-medical-toc-context-' . sanitize_html_class( $context ),
		);

		if ( ! empty( $instance['sticky'] ) ) {
			$classes[] = 'rx-medical-toc-sticky';
		}

		if ( ! empty( $instance['collapsed_default'] ) ) {
			$classes[] = 'rx-medical-toc-collapsed';
		}

		if ( ! empty( $instance['custom_class'] ) ) {
			$classes[] = sanitize_html_class( $instance['custom_class'] );
		}

		$style_vars = sprintf(
			'--rx-toc-accent:%1$s;--rx-toc-bg:%2$s;--rx-toc-border:%3$s;--rx-toc-text:%4$s;--rx-toc-offset:%5$dpx;',
			esc_attr( $instance['accent_color'] ),
			esc_attr( $instance['background_color'] ),
			esc_attr( $instance['border_color'] ),
			esc_attr( $instance['text_color'] ),
			absint( $instance['scroll_offset'] )
		);

		ob_start();
		?>

		<nav id="<?php echo esc_attr( $uid ); ?>"
			class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"
			style="<?php echo esc_attr( $style_vars ); ?>"
			aria-label="<?php echo esc_attr__( 'Medical article table of contents', 'rx-theme' ); ?>"
			data-rx-toc
			data-offset="<?php echo esc_attr( absint( $instance['scroll_offset'] ) ); ?>"
			data-smooth="<?php echo esc_attr( ! empty( $instance['smooth_scroll'] ) ? '1' : '0' ); ?>"
			data-active="<?php echo esc_attr( ! empty( $instance['active_highlight'] ) ? '1' : '0' ); ?>">

			<?php if ( ! empty( $instance['show_progress'] ) ) : ?>
				<div class="rx-toc-progress-wrap" aria-hidden="true">
					<span class="rx-toc-progress-bar"></span>
				</div>
			<?php endif; ?>

			<div class="rx-toc-header">
				<div class="rx-toc-title-wrap">
					<?php if ( ! empty( $instance['title'] ) ) : ?>
						<strong class="rx-toc-title"><?php echo esc_html( $instance['title'] ); ?></strong>
					<?php endif; ?>

					<?php if ( ! empty( $instance['intro_text'] ) ) : ?>
						<p class="rx-toc-intro"><?php echo esc_html( $instance['intro_text'] ); ?></p>
					<?php endif; ?>
				</div>

				<?php if ( ! empty( $instance['collapsible'] ) ) : ?>
					<button type="button"
						class="rx-toc-toggle"
						aria-expanded="<?php echo empty( $instance['collapsed_default'] ) ? 'true' : 'false'; ?>"
						aria-controls="<?php echo esc_attr( $uid ); ?>-body">
						<span class="rx-toc-toggle-open"><?php esc_html_e( 'Hide', 'rx-theme' ); ?></span>
						<span class="rx-toc-toggle-close"><?php esc_html_e( 'Show', 'rx-theme' ); ?></span>
					</button>
				<?php endif; ?>
			</div>

			<?php if ( ! empty( $instance['show_reading_time'] ) || ! empty( $instance['show_word_count'] ) || ! empty( $instance['show_summary'] ) ) : ?>
				<div class="rx-toc-meta">
					<?php if ( ! empty( $instance['show_reading_time'] ) ) : ?>
						<span class="rx-toc-meta-item">
							<?php echo esc_html( sprintf( _n( '%s min read', '%s min read', $reading_time, 'rx-theme' ), number_format_i18n( $reading_time ) ) ); ?>
						</span>
					<?php endif; ?>

					<?php if ( ! empty( $instance['show_word_count'] ) ) : ?>
						<span class="rx-toc-meta-item">
							<?php echo esc_html( sprintf( __( '%s words', 'rx-theme' ), number_format_i18n( $word_count ) ) ); ?>
						</span>
					<?php endif; ?>

					<span class="rx-toc-meta-item">
						<?php echo esc_html( sprintf( _n( '%s section', '%s sections', count( $headings ), 'rx-theme' ), number_format_i18n( count( $headings ) ) ) ); ?>
					</span>
				</div>
			<?php endif; ?>

			<?php if ( ! empty( $instance['show_summary'] ) ) : ?>
				<div class="rx-toc-summary">
					<?php
					echo esc_html(
						sprintf(
							__( 'This medical guide is organized into %1$s main sections for easier reading and faster navigation.', 'rx-theme' ),
							number_format_i18n( count( $headings ) )
						)
					);
					?>
				</div>
			<?php endif; ?>

			<div id="<?php echo esc_attr( $uid ); ?>-body" class="rx-toc-body">
				<?php echo $this->render_toc_list( $headings, $instance ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
			</div>
		</nav>

		<?php
		$html = ob_get_clean();

		if ( ! empty( $instance['cache_enabled'] ) ) {
			set_transient( $cache_key, $html, DAY_IN_SECONDS );
		}

		$this->enqueue_inline_assets( $instance );

		return $html;
	}

	/**
	 * Extract headings from post content.
	 */
	protected function extract_headings( $content, $instance ) {
		$levels          = $this->normalize_heading_levels( $instance['heading_levels'] );
		$exclude_texts   = $this->csv_to_array( $instance['exclude_headings'] );
		$exclude_classes = $this->csv_to_array( $instance['exclude_classes'] );

		if ( empty( $levels ) ) {
			$levels = array( 'h2', 'h3', 'h4' );
		}

		$pattern = '/<(' . implode( '|', array_map( 'preg_quote', $levels ) ) . ')([^>]*)>(.*?)<\/\1>/is';

		preg_match_all( $pattern, $content, $matches, PREG_SET_ORDER );

		if ( empty( $matches ) ) {
			return array();
		}

		$headings = array();

		foreach ( $matches as $match ) {
			$tag        = strtolower( $match[1] );
			$attributes = $match[2];
			$html       = $match[3];
			$text       = trim( wp_strip_all_tags( $html ) );

			if ( empty( $text ) ) {
				continue;
			}

			if ( $this->heading_should_be_excluded( $text, $attributes, $exclude_texts, $exclude_classes ) ) {
				continue;
			}

			$id = $this->extract_id_from_attributes( $attributes );

			if ( empty( $id ) ) {
				$id = $this->generate_heading_id( $text );
			}

			$headings[] = array(
				'tag'   => $tag,
				'level' => absint( str_replace( 'h', '', $tag ) ),
				'id'    => sanitize_html_class( $id ),
				'text'  => $text,
			);
		}

		return $this->make_unique_heading_ids( $headings );
	}

	/**
	 * Add IDs to frontend headings.
	 */
	public function inject_heading_ids_into_content( $content ) {
		if ( is_admin() || ! is_singular() || empty( $content ) ) {
			return $content;
		}

		$instance = $this->defaults;
		$levels   = $this->normalize_heading_levels( $instance['heading_levels'] );

		$pattern = '/<(' . implode( '|', array_map( 'preg_quote', $levels ) ) . ')([^>]*)>(.*?)<\/\1>/is';

		$used = array();

		$content = preg_replace_callback(
			$pattern,
			function( $matches ) use ( &$used ) {
				$tag        = strtolower( $matches[1] );
				$attributes = $matches[2];
				$inner_html  = $matches[3];
				$text        = trim( wp_strip_all_tags( $inner_html ) );

				if ( empty( $text ) ) {
					return $matches[0];
				}

				if ( preg_match( '/\sid=["\']([^"\']+)["\']/i', $attributes ) ) {
					return $matches[0];
				}

				$id = $this->generate_heading_id( $text );

				if ( isset( $used[ $id ] ) ) {
					$used[ $id ]++;
					$id = $id . '-' . $used[ $id ];
				} else {
					$used[ $id ] = 1;
				}

				$copy_button = '';

				/**
				 * Filter: rx_medical_toc_add_heading_anchor_button
				 */
				if ( apply_filters( 'rx_medical_toc_add_heading_anchor_button', true ) ) {
					$copy_button = sprintf(
						' <button class="rx-heading-copy-link" type="button" data-copy-link="#%1$s" aria-label="%2$s">#</button>',
						esc_attr( $id ),
						esc_attr__( 'Copy link to this section', 'rx-theme' )
					);
				}

				return sprintf(
					'<%1$s id="%2$s"%3$s>%4$s%5$s</%1$s>',
					tag_escape( $tag ),
					esc_attr( $id ),
					$attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
					$inner_html, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
					$copy_button // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				);
			},
			$content
		);

		return $content;
	}

	/**
	 * Render TOC list.
	 */
	protected function render_toc_list( $headings, $instance ) {
		if ( empty( $headings ) ) {
			return '';
		}

		if ( empty( $instance['nested'] ) ) {
			return $this->render_flat_toc_list( $headings, $instance );
		}

		return $this->render_nested_toc_list( $headings, $instance );
	}

	/**
	 * Render flat TOC.
	 */
	protected function render_flat_toc_list( $headings, $instance ) {
		$list_tag = ! empty( $instance['numbered'] ) ? 'ol' : 'ul';

		ob_start();
		?>
		<<?php echo tag_escape( $list_tag ); ?> class="rx-toc-list rx-toc-list-flat">
			<?php foreach ( $headings as $index => $heading ) : ?>
				<li class="rx-toc-item rx-toc-level-<?php echo esc_attr( $heading['level'] ); ?>">
					<a class="rx-toc-link" href="#<?php echo esc_attr( $heading['id'] ); ?>" data-target="<?php echo esc_attr( $heading['id'] ); ?>">
						<?php if ( ! empty( $instance['numbered'] ) ) : ?>
							<span class="rx-toc-number"><?php echo esc_html( $index + 1 ); ?>.</span>
						<?php endif; ?>
						<span class="rx-toc-text"><?php echo esc_html( $heading['text'] ); ?></span>
					</a>
				</li>
			<?php endforeach; ?>
		</<?php echo tag_escape( $list_tag ); ?>>
		<?php
		return ob_get_clean();
	}

	/**
	 * Render nested TOC.
	 */
	protected function render_nested_toc_list( $headings, $instance ) {
		$list_tag      = ! empty( $instance['numbered'] ) ? 'ol' : 'ul';
		$output        = '';
		$current_level = 0;
		$counters      = array();

		foreach ( $headings as $heading ) {
			$level = absint( $heading['level'] );

			if ( 0 === $current_level ) {
				$output       .= '<' . tag_escape( $list_tag ) . ' class="rx-toc-list rx-toc-list-nested">';
				$current_level = $level;
			}

			while ( $level > $current_level ) {
				$output       .= '<' . tag_escape( $list_tag ) . ' class="rx-toc-sub-list">';
				$current_level++;
			}

			while ( $level < $current_level ) {
				$output       .= '</li></' . tag_escape( $list_tag ) . '>';
				$current_level--;
			}

			if ( isset( $counters[ $level ] ) ) {
				$counters[ $level ]++;
			} else {
				$counters[ $level ] = 1;
			}

			foreach ( array_keys( $counters ) as $counter_level ) {
				if ( $counter_level > $level ) {
					unset( $counters[ $counter_level ] );
				}
			}

			$number = implode( '.', array_values( $counters ) );

			$output .= '<li class="rx-toc-item rx-toc-level-' . esc_attr( $level ) . '">';
			$output .= '<a class="rx-toc-link" href="#' . esc_attr( $heading['id'] ) . '" data-target="' . esc_attr( $heading['id'] ) . '">';

			if ( ! empty( $instance['numbered'] ) ) {
				$output .= '<span class="rx-toc-number">' . esc_html( $number ) . '.</span>';
			}

			$output .= '<span class="rx-toc-text">' . esc_html( $heading['text'] ) . '</span>';
			$output .= '</a>';
		}

		while ( $current_level > 0 ) {
			$output .= '</li></' . tag_escape( $list_tag ) . '>';
			$current_level--;
		}

		return $output;
	}

	/**
	 * Enqueue inline CSS and JS.
	 */
	protected function enqueue_inline_assets( $instance ) {
		wp_enqueue_style( 'rx-medical-toc-widget' );
		wp_enqueue_script( 'rx-medical-toc-widget' );

		$css = $this->get_inline_css();
		$js  = $this->get_inline_js();

		wp_add_inline_style( 'rx-medical-toc-widget', $css );
		wp_add_inline_script( 'rx-medical-toc-widget', $js );
	}

	/**
	 * Inline CSS.
	 */
	protected function get_inline_css() {
		return '
.rx-medical-toc {
	position: relative;
	margin: 24px 0;
	padding: 18px;
	color: var(--rx-toc-text);
	background: var(--rx-toc-bg);
	border: 1px solid var(--rx-toc-border);
	border-radius: 16px;
	box-shadow: 0 8px 24px rgba(15, 23, 42, .06);
	overflow: hidden;
}

.rx-medical-toc-sticky {
	position: sticky;
	top: var(--rx-toc-offset);
	z-index: 20;
}

.rx-toc-progress-wrap {
	position: absolute;
	top: 0;
	left: 0;
	right: 0;
	height: 4px;
	background: rgba(15, 23, 42, .08);
}

.rx-toc-progress-bar {
	display: block;
	width: 0%;
	height: 100%;
	background: var(--rx-toc-accent);
	transition: width .15s linear;
}

.rx-toc-header {
	display: flex;
	align-items: flex-start;
	justify-content: space-between;
	gap: 12px;
	margin-bottom: 12px;
}

.rx-toc-title {
	display: block;
	font-size: 18px;
	line-height: 1.3;
	color: var(--rx-toc-text);
}

.rx-toc-intro {
	margin: 6px 0 0;
	font-size: 14px;
	line-height: 1.55;
	opacity: .82;
}

.rx-toc-toggle {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	min-width: 64px;
	padding: 7px 10px;
	font-size: 13px;
	font-weight: 600;
	color: var(--rx-toc-accent);
	background: rgba(11, 125, 119, .08);
	border: 1px solid rgba(11, 125, 119, .18);
	border-radius: 999px;
	cursor: pointer;
}

.rx-toc-toggle:hover,
.rx-toc-toggle:focus {
	outline: none;
	background: rgba(11, 125, 119, .14);
}

.rx-medical-toc-collapsed .rx-toc-body,
.rx-medical-toc-collapsed .rx-toc-summary,
.rx-medical-toc-collapsed .rx-toc-meta {
	display: none;
}

.rx-medical-toc .rx-toc-toggle-close {
	display: none;
}

.rx-medical-toc-collapsed .rx-toc-toggle-open {
	display: none;
}

.rx-medical-toc-collapsed .rx-toc-toggle-close {
	display: inline;
}

.rx-toc-meta {
	display: flex;
	flex-wrap: wrap;
	gap: 8px;
	margin: 0 0 12px;
}

.rx-toc-meta-item {
	display: inline-flex;
	align-items: center;
	padding: 5px 9px;
	font-size: 12px;
	line-height: 1;
	border-radius: 999px;
	background: rgba(15, 23, 42, .06);
}

.rx-toc-summary {
	margin: 0 0 14px;
	padding: 10px 12px;
	font-size: 14px;
	line-height: 1.55;
	background: rgba(11, 125, 119, .06);
	border-left: 4px solid var(--rx-toc-accent);
	border-radius: 10px;
}

.rx-toc-list,
.rx-toc-sub-list {
	margin: 0;
	padding-left: 20px;
}

.rx-toc-list-flat {
	padding-left: 0;
	list-style: none;
}

.rx-toc-item {
	margin: 7px 0;
	line-height: 1.45;
}

.rx-toc-list-flat .rx-toc-item {
	list-style: none;
}

.rx-toc-link {
	display: flex;
	gap: 7px;
	align-items: flex-start;
	padding: 6px 8px;
	color: var(--rx-toc-text);
	text-decoration: none;
	border-radius: 10px;
	transition: background .15s ease, color .15s ease, transform .15s ease;
}

.rx-toc-link:hover,
.rx-toc-link:focus {
	color: var(--rx-toc-accent);
	background: rgba(11, 125, 119, .08);
	outline: none;
	transform: translateX(2px);
}

.rx-toc-link.is-active {
	color: var(--rx-toc-accent);
	background: rgba(11, 125, 119, .12);
	font-weight: 700;
}

.rx-toc-number {
	flex: 0 0 auto;
	font-weight: 700;
	color: var(--rx-toc-accent);
}

.rx-toc-text {
	flex: 1 1 auto;
}

.rx-toc-level-3 .rx-toc-link {
	font-size: 14px;
	opacity: .92;
}

.rx-toc-level-4 .rx-toc-link,
.rx-toc-level-5 .rx-toc-link,
.rx-toc-level-6 .rx-toc-link {
	font-size: 13px;
	opacity: .86;
}

.rx-heading-copy-link {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	margin-left: 8px;
	width: 24px;
	height: 24px;
	font-size: 13px;
	line-height: 1;
	color: var(--rx-toc-accent, #0b7d77);
	background: rgba(11, 125, 119, .08);
	border: 1px solid rgba(11, 125, 119, .18);
	border-radius: 999px;
	cursor: pointer;
	vertical-align: middle;
	opacity: .65;
}

.rx-heading-copy-link:hover,
.rx-heading-copy-link:focus {
	opacity: 1;
	outline: none;
}

html {
	scroll-behavior: smooth;
}

@media (max-width: 782px) {
	.rx-medical-toc,
	.rx-medical-toc-sticky {
		position: relative;
		top: auto;
		border-radius: 14px;
		padding: 15px;
	}

	.rx-toc-header {
		align-items: center;
	}

	.rx-toc-title {
		font-size: 16px;
	}

	.rx-toc-link {
		padding: 8px;
	}
}

@media print {
	.rx-medical-toc {
		box-shadow: none;
		break-inside: avoid;
	}

	.rx-toc-toggle,
	.rx-heading-copy-link,
	.rx-toc-progress-wrap {
		display: none !important;
	}
}
';
	}

	/**
	 * Inline JS.
	 */
	protected function get_inline_js() {
		return '
(function() {
	"use strict";

	function ready(fn) {
		if (document.readyState !== "loading") {
			fn();
		} else {
			document.addEventListener("DOMContentLoaded", fn);
		}
	}

	function copyToClipboard(text) {
		if (navigator.clipboard && window.isSecureContext) {
			return navigator.clipboard.writeText(text);
		}

		var textarea = document.createElement("textarea");
		textarea.value = text;
		textarea.style.position = "fixed";
		textarea.style.left = "-9999px";
		document.body.appendChild(textarea);
		textarea.focus();
		textarea.select();

		try {
			document.execCommand("copy");
		} catch (e) {}

		document.body.removeChild(textarea);
		return Promise.resolve();
	}

	ready(function() {
		var tocBoxes = document.querySelectorAll("[data-rx-toc]");
		var links = [];

		tocBoxes.forEach(function(toc) {
			var offset = parseInt(toc.getAttribute("data-offset") || "90", 10);
			var smooth = toc.getAttribute("data-smooth") === "1";
			var active = toc.getAttribute("data-active") === "1";
			var progress = toc.querySelector(".rx-toc-progress-bar");
			var toggle = toc.querySelector(".rx-toc-toggle");

			if (toggle) {
				toggle.addEventListener("click", function() {
					var collapsed = toc.classList.toggle("rx-medical-toc-collapsed");
					toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
				});
			}

			toc.querySelectorAll(".rx-toc-link").forEach(function(link) {
				links.push(link);

				link.addEventListener("click", function(e) {
					var id = link.getAttribute("data-target");
					var target = id ? document.getElementById(id) : null;

					if (!target) {
						return;
					}

					e.preventDefault();

					var top = target.getBoundingClientRect().top + window.pageYOffset - offset;

					window.scrollTo({
						top: top,
						behavior: smooth ? "smooth" : "auto"
					});

					if (history.pushState) {
						history.pushState(null, "", "#" + id);
					} else {
						window.location.hash = id;
					}
				});
			});

			function updateProgress() {
				if (!progress) {
					return;
				}

				var doc = document.documentElement;
				var scrollTop = doc.scrollTop || document.body.scrollTop;
				var scrollHeight = doc.scrollHeight - doc.clientHeight;
				var percent = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0;

				progress.style.width = Math.min(100, Math.max(0, percent)) + "%";
			}

			function updateActive() {
				if (!active) {
					return;
				}

				var currentId = "";
				var headings = [];

				toc.querySelectorAll(".rx-toc-link").forEach(function(link) {
					var id = link.getAttribute("data-target");
					var heading = id ? document.getElementById(id) : null;

					if (heading) {
						headings.push(heading);
					}
				});

				headings.forEach(function(heading) {
					var top = heading.getBoundingClientRect().top;
					if (top <= offset + 20) {
						currentId = heading.id;
					}
				});

				toc.querySelectorAll(".rx-toc-link").forEach(function(link) {
					link.classList.toggle("is-active", link.getAttribute("data-target") === currentId);
				});
			}

			window.addEventListener("scroll", function() {
				updateProgress();
				updateActive();
			}, { passive: true });

			window.addEventListener("resize", function() {
				updateProgress();
				updateActive();
			});

			updateProgress();
			updateActive();
		});

		document.querySelectorAll(".rx-heading-copy-link").forEach(function(button) {
			button.addEventListener("click", function() {
				var hash = button.getAttribute("data-copy-link");
				if (!hash) {
					return;
				}

				var url = window.location.origin + window.location.pathname + hash;

				copyToClipboard(url).then(function() {
					var original = button.textContent;
					button.textContent = "✓";
					setTimeout(function() {
						button.textContent = original;
					}, 1300);
				});
			});
		});
	});
})();
';
	}

	/**
	 * Generate heading ID.
	 */
	protected function generate_heading_id( $text ) {
		$id = sanitize_title( $text );

		if ( empty( $id ) ) {
			$id = 'rx-section-' . wp_rand( 1000, 9999 );
		}

		return $id;
	}

	/**
	 * Make heading IDs unique.
	 */
	protected function make_unique_heading_ids( $headings ) {
		$used = array();

		foreach ( $headings as $index => $heading ) {
			$id = $heading['id'];

			if ( isset( $used[ $id ] ) ) {
				$used[ $id ]++;
				$headings[ $index ]['id'] = $id . '-' . $used[ $id ];
			} else {
				$used[ $id ] = 1;
			}
		}

		return $headings;
	}

	/**
	 * Extract ID from heading attributes.
	 */
	protected function extract_id_from_attributes( $attributes ) {
		if ( preg_match( '/\sid=["\']([^"\']+)["\']/i', $attributes, $matches ) ) {
			return sanitize_html_class( $matches[1] );
		}

		return '';
	}

	/**
	 * Check excluded heading.
	 */
	protected function heading_should_be_excluded( $text, $attributes, $exclude_texts, $exclude_classes ) {
		foreach ( $exclude_texts as $exclude ) {
			if ( '' !== $exclude && false !== stripos( $text, $exclude ) ) {
				return true;
			}
		}

		if ( ! empty( $exclude_classes ) && preg_match( '/\sclass=["\']([^"\']+)["\']/i', $attributes, $matches ) ) {
			$classes = preg_split( '/\s+/', $matches[1] );

			foreach ( $classes as $class ) {
				if ( in_array( $class, $exclude_classes, true ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Normalize heading levels.
	 */
	protected function normalize_heading_levels( $levels ) {
		$levels = $this->csv_to_array( $levels );
		$valid  = array();

		foreach ( $levels as $level ) {
			$level = strtolower( trim( $level ) );

			if ( preg_match( '/^h[2-6]$/', $level ) ) {
				$valid[] = $level;
			}
		}

		return array_values( array_unique( $valid ) );
	}

	/**
	 * CSV to clean array.
	 */
	protected function csv_to_array( $value ) {
		if ( empty( $value ) ) {
			return array();
		}

		$items = explode( ',', $value );
		$items = array_map( 'trim', $items );
		$items = array_filter( $items, 'strlen' );

		return array_values( array_unique( $items ) );
	}

	/**
	 * Get word count.
	 */
	protected function get_word_count( $content ) {
		$text = wp_strip_all_tags( strip_shortcodes( $content ) );

		return str_word_count( $text );
	}

	/**
	 * Get reading time.
	 */
	protected function get_reading_time( $content ) {
		$word_count = $this->get_word_count( $content );
		$minutes    = ceil( $word_count / 220 );

		return max( 1, absint( $minutes ) );
	}

	/**
	 * Clear cache for current site.
	 */
	protected function clear_post_cache() {
		global $wpdb;

		if ( ! $wpdb ) {
			return;
		}

		$wpdb->query(
			"DELETE FROM {$wpdb->options}
			WHERE option_name LIKE '_transient_rx_medical_toc_%'
			OR option_name LIKE '_transient_timeout_rx_medical_toc_%'"
		);
	}
}

endif;

/**
 * Register widget.
 */
if ( ! function_exists( 'rx_register_medical_toc_widget' ) ) {
	function rx_register_medical_toc_widget() {
		register_widget( 'RX_Medical_TOC_Widget' );
	}
}
add_action( 'widgets_init', 'rx_register_medical_toc_widget' );

Now include this file in your theme functions.php:

/**
 * RX Theme Widgets.
 */
require_once get_template_directory() . '/inc/widgets/medical-toc-widget.php';

Use shortcode anywhere in post/page:

[rx_medical_toc]

Advanced shortcode example:

[rx_medical_toc title="Medical Article Contents" levels="h2,h3,h4" numbered="1" sticky="0" progress="1" copy_links="1" summary="1" offset="100"]

Important: for best result, your article should use proper headings like:

<h2>Definition</h2>
<h2>Causes</h2>
<h2>Symptoms</h2>
<h2>Diagnostic Tests</h2>
<h2>Treatments</h2>
<h2>Prevention</h2>
<h2>FAQs</h2>

Leave a Reply

Your email address will not be published. Required fields are marked *