diff --git a/README.md b/README.md
index ff48eed..0ed2e0c 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,16 @@ Register custom query configurations in PHP that can be selected from a dropdown
- Queries work in both the editor preview and on the frontend
- Automatically hooks into all public post types via the REST API
+### 7. Sticky Posts
+
+Pin a hand-picked, ordered set of posts to the front of a query loop. Selected posts render first, in the order chosen in the editor; everything else follows in whatever order the block's own settings produce.
+
+This is an *ordering* feature, not a filter. A post the query would not return anyway — wrong post type, excluded by a taxonomy filter, not published — is **not** pulled in by pinning it. "Which posts appear" stays the job of the query settings; this only decides what order they appear in.
+
+The ordering is applied in SQL via `posts_orderby` rather than by re-sorting results afterwards, so it composes correctly with `posts_per_page` and pagination: pinned posts lead the whole result set, not merely the page being rendered.
+
+**Known limitation:** Elasticsearch bypasses `posts_orderby`. A loop routed through ElasticPress (including via this plugin's own ElasticPress toggle) ignores pinning.
+
## Installation
1. Upload the plugin to your `/wp-content/plugins/` directory
@@ -89,6 +99,7 @@ See [tests/e2e/README.md](tests/e2e/README.md) for more details on the test setu
- **Hide on paginated pages**: Toggle to hide this block on page 2+
- **Exclude already displayed posts**: Toggle to avoid showing duplicate posts
4. For non-inherited queries with multiple Post Template blocks, select each `core/post-template` and set **Posts per template** to control how many posts each template shows
+5. To pin posts to the front, open the **Sticky Posts** panel, search for a post and select it. Use the arrows to reorder pinned posts, or **Unpin** to remove one
## Block Context
@@ -99,7 +110,8 @@ The plugin exposes an `hmQueryLoop` context object from `core/query` to `core/po
{
perPage: number | undefined, // Custom posts per page value
hideOnPaged: boolean, // Whether to hide on paginated pages
- excludeDisplayed: boolean // Whether to exclude displayed posts
+ excludeDisplayed: boolean, // Whether to exclude displayed posts
+ stickyPosts: number[] | undefined // Post IDs pinned to the front, in order
}
```
diff --git a/hm-query-loop.php b/hm-query-loop.php
index 761ec85..05543ca 100644
--- a/hm-query-loop.php
+++ b/hm-query-loop.php
@@ -27,6 +27,9 @@
// Load query presets functionality.
require_once HM_QUERY_LOOP_PATH . 'inc/query-presets.php';
+// Load sticky posts functionality.
+require_once HM_QUERY_LOOP_PATH . 'inc/sticky-posts.php';
+
/**
* Initialize the plugin.
*/
@@ -52,6 +55,9 @@ function init() {
// Initialize query presets functionality.
QueryPresets\init();
+
+ // Initialize sticky posts functionality.
+ StickyPosts\bootstrap();
}
add_action( 'init', __NAMESPACE__ . '\\init', 9 );
@@ -504,6 +510,14 @@ function modify_query_from_block_attrs( $query = [], $attrs = [] ) {
}
}
+ // Carry pinned post IDs through to the ORDER BY clause. This is an
+ // ordering concern rather than a query var, so it is stashed on the query
+ // and read back in StickyPosts\apply_sticky_order().
+ $sticky_posts = StickyPosts\normalize_ids( $settings['stickyPosts'] ?? [] );
+ if ( ! empty( $sticky_posts ) ) {
+ $query[ StickyPosts\QUERY_VAR ] = $sticky_posts;
+ }
+
// Exclude already displayed posts for this loop if enabled.
if ( isset( $settings['excludeDisplayedForCurrentLoop'] ) ) {
$query['query_id'] = $settings['excludeDisplayedForCurrentLoop'];
diff --git a/inc/sticky-posts.php b/inc/sticky-posts.php
new file mode 100644
index 0000000..6b4dc31
--- /dev/null
+++ b/inc/sticky-posts.php
@@ -0,0 +1,112 @@
+get( QUERY_VAR ) );
+
+ if ( empty( $ids ) ) {
+ return $orderby;
+ }
+
+ global $wpdb;
+
+ // Safe to interpolate: every value has been through absint() and the
+ // list is built here rather than taken from the request.
+ $field = sprintf(
+ 'FIELD( %s.ID, %s )',
+ $wpdb->posts,
+ implode( ',', $ids )
+ );
+
+ $sticky = sprintf( '%1$s = 0 ASC, %1$s ASC', $field );
+
+ $orderby = is_string( $orderby ) ? trim( $orderby ) : '';
+
+ return $orderby === '' ? $sticky : $sticky . ', ' . $orderby;
+}
diff --git a/src/index.js b/src/index.js
index 04e9f79..24a477e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -14,6 +14,7 @@ import {
Spinner,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
+import StickyPostsControl from './sticky-posts-control';
import {
createContext,
useContext,
@@ -382,6 +383,25 @@ const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => {
/>
) }
+
+ {
+ const { stickyPosts: _omit, ...rest } =
+ hmQueryLoop;
+ setAttributes( {
+ hmQueryLoop:
+ nextIds.length > 0
+ ? { ...rest, stickyPosts: nextIds }
+ : rest,
+ } );
+ } }
+ />
+
>
);
diff --git a/src/index.scss b/src/index.scss
index 677cfdb..69a2613 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -35,3 +35,68 @@
min-height: 200px;
}
}
+
+/**
+ * Sticky Posts control.
+ *
+ * Mirrors the ordered-list + search-results shape used by the other
+ * post-selection controls in this plugin's inspector.
+ */
+.hm-query-loop-sticky {
+ &-list,
+ &-results {
+ list-style: none;
+ margin: 0 0 8px;
+ padding: 0;
+ }
+
+ &-item {
+ align-items: center;
+ border-bottom: 1px solid #e0e0e0;
+ display: flex;
+ gap: 4px;
+ justify-content: space-between;
+ padding: 4px 0;
+
+ &__title {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__actions {
+ display: flex;
+ flex-shrink: 0;
+ }
+ }
+
+ &-results li {
+ margin: 0;
+ }
+
+ &-result {
+ text-align: left;
+ width: 100%;
+ }
+
+ &-loading {
+ align-items: center;
+ display: flex;
+ gap: 8px;
+ }
+
+ &-error {
+ color: #cc1818;
+ }
+
+ &-empty,
+ &-error {
+ font-size: 12px;
+ margin: 4px 0;
+ }
+
+ &-clear {
+ margin-top: 4px;
+ }
+}
diff --git a/src/sticky-posts-control.js b/src/sticky-posts-control.js
new file mode 100644
index 0000000..703964f
--- /dev/null
+++ b/src/sticky-posts-control.js
@@ -0,0 +1,386 @@
+/**
+ * Sticky Posts control for the Query Loop block.
+ *
+ * Renders a post search plus an ordered selection list that writes to the
+ * `hmQueryLoop.stickyPosts` attribute. Selected posts are pinned to the front
+ * of the loop, in the order shown here; everything else keeps the ordering the
+ * block's own settings produce.
+ *
+ * This pins rather than filters: a post that the query would not return anyway
+ * is not pulled in by pinning it. That keeps "which posts appear" the job of
+ * the query settings, and "what order they appear in" the job of this control.
+ *
+ * The search and resolution hooks here follow the same approach as the
+ * Curated Posts control proposed in #15.
+ */
+
+import apiFetch from '@wordpress/api-fetch';
+import { useSelect } from '@wordpress/data';
+import {
+ Button,
+ BaseControl,
+ TextControl,
+ Spinner,
+} from '@wordpress/components';
+import { useEffect, useMemo, useState, useRef } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { addQueryArgs } from '@wordpress/url';
+
+const SEARCH_DEBOUNCE_MS = 300;
+const SEARCH_RESULTS_PER_PAGE = 10;
+
+/**
+ * Coerce a stored sticky list to a clean array of post IDs.
+ *
+ * @param {*} value Raw attribute value.
+ * @return {number[]} Cleaned IDs, in order.
+ */
+function normalizeIds( value ) {
+ if ( ! Array.isArray( value ) ) {
+ return [];
+ }
+ return value
+ .map( ( id ) => parseInt( id, 10 ) )
+ .filter( ( id ) => Number.isInteger( id ) && id > 0 );
+}
+
+/**
+ * Resolve post IDs to lightweight {id, title} records via the `core` store.
+ *
+ * Scoped to the query's post type so the right REST entity is used.
+ *
+ * @param {number[]} ids Selected post IDs in display order.
+ * @param {string} postType Current query post type.
+ * @return {{records: Array, isResolving: boolean}} Resolved records and loading state.
+ */
+function useResolvedPosts( ids, postType ) {
+ const idsKey = ids.join( ',' );
+ return useSelect(
+ ( select ) => {
+ if ( ! ids || ids.length === 0 ) {
+ return { records: [], isResolving: false };
+ }
+
+ const { getEntityRecords, isResolving } = select( 'core' );
+ const type = postType && postType !== 'any' ? postType : 'post';
+
+ const query = {
+ include: ids,
+ per_page: ids.length,
+ orderby: 'include',
+ _fields: 'id,title,type',
+ context: 'view',
+ };
+
+ return {
+ records: getEntityRecords( 'postType', type, query ) || [],
+ isResolving: isResolving( 'getEntityRecords', [
+ 'postType',
+ type,
+ query,
+ ] ),
+ };
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [ idsKey, postType ]
+ );
+}
+
+/**
+ * Search posts scoped to the current query's post type.
+ *
+ * Uses the post type's own REST collection endpoint rather than
+ * `/wp/v2/search`, since the latter only indexes post types that opt in via a
+ * search handler — most custom post types do not.
+ *
+ * @param {string} term Search term.
+ * @param {string} postType Post type slug.
+ * @return {{results: Array, isLoading: boolean, error: string|null}} Search state.
+ */
+function usePostSearch( term, postType ) {
+ const [ results, setResults ] = useState( [] );
+ const [ isLoading, setIsLoading ] = useState( false );
+ const [ error, setError ] = useState( null );
+ const requestRef = useRef( 0 );
+
+ const restBase = useSelect(
+ ( select ) => {
+ const type = postType && postType !== 'any' ? postType : 'post';
+ return select( 'core' ).getPostType( type )?.rest_base || type;
+ },
+ [ postType ]
+ );
+
+ useEffect( () => {
+ if ( ! term || term.length < 2 ) {
+ setResults( [] );
+ setIsLoading( false );
+ setError( null );
+ return;
+ }
+
+ const requestId = ++requestRef.current;
+ setIsLoading( true );
+ setError( null );
+
+ apiFetch( {
+ path: addQueryArgs( `/wp/v2/${ restBase }`, {
+ search: term,
+ per_page: SEARCH_RESULTS_PER_PAGE,
+ _fields: 'id,title',
+ } ),
+ } )
+ .then( ( data ) => {
+ if ( requestId !== requestRef.current ) {
+ return;
+ }
+ setResults( Array.isArray( data ) ? data : [] );
+ setIsLoading( false );
+ } )
+ .catch( ( err ) => {
+ if ( requestId !== requestRef.current ) {
+ return;
+ }
+ setError(
+ err?.message || __( 'Search failed.', 'hm-query-loop' )
+ );
+ setIsLoading( false );
+ } );
+ }, [ term, restBase ] );
+
+ return { results, isLoading, error };
+}
+
+/**
+ * Resolve a post's display title, falling back while it is still loading.
+ *
+ * @param {Object} record Resolved post record, if any.
+ * @param {number} id Post ID.
+ * @param {boolean} isResolving Whether the record is still being fetched.
+ * @return {string} Title markup string.
+ */
+function getTitle( record, id, isResolving ) {
+ return (
+ record?.title?.rendered ||
+ record?.title ||
+ ( isResolving
+ ? __( 'Loading…', 'hm-query-loop' )
+ : sprintf(
+ /* translators: %d: post ID */
+ __( 'Post #%d', 'hm-query-loop' ),
+ id
+ ) )
+ );
+}
+
+/**
+ * The control rendered inside the Query Loop block's inspector panel.
+ *
+ * @param {Object} props
+ * @param {Object} props.query Current `query` block attribute.
+ * @param {number[]} props.stickyPosts Currently pinned post IDs, in order.
+ * @param {Function} props.onChange Receives the next pinned ID array.
+ * @return {Element} The rendered control.
+ */
+export default function StickyPostsControl( { query, stickyPosts, onChange } ) {
+ const sticky = useMemo(
+ () => normalizeIds( stickyPosts ),
+ [ stickyPosts ]
+ );
+ const postType = query?.postType || 'post';
+
+ const [ searchTerm, setSearchTerm ] = useState( '' );
+ const [ debouncedTerm, setDebouncedTerm ] = useState( '' );
+
+ useEffect( () => {
+ const id = setTimeout(
+ () => setDebouncedTerm( searchTerm ),
+ SEARCH_DEBOUNCE_MS
+ );
+ return () => clearTimeout( id );
+ }, [ searchTerm ] );
+
+ const { records, isResolving } = useResolvedPosts( sticky, postType );
+ const { results, isLoading, error } = usePostSearch(
+ debouncedTerm,
+ postType
+ );
+
+ const recordsById = useMemo( () => {
+ const map = {};
+ for ( const record of records ) {
+ map[ record.id ] = record;
+ }
+ return map;
+ }, [ records ] );
+
+ const addPost = ( id ) => {
+ if ( sticky.includes( id ) ) {
+ return;
+ }
+ onChange( [ ...sticky, id ] );
+ setSearchTerm( '' );
+ setDebouncedTerm( '' );
+ };
+
+ const removePost = ( id ) => {
+ onChange( sticky.filter( ( existing ) => existing !== id ) );
+ };
+
+ const movePost = ( id, direction ) => {
+ const index = sticky.indexOf( id );
+ const target = index + direction;
+ if ( index === -1 || target < 0 || target >= sticky.length ) {
+ return;
+ }
+ const next = [ ...sticky ];
+ const [ moved ] = next.splice( index, 1 );
+ next.splice( target, 0, moved );
+ onChange( next );
+ };
+
+ const filteredResults = results.filter(
+ ( result ) => ! sticky.includes( result.id )
+ );
+
+ return (
+
+
+ { sticky.length > 0 && (
+
+ { sticky.map( ( id, index ) => {
+ const title = getTitle(
+ recordsById[ id ],
+ id,
+ isResolving
+ );
+ return (
+ -
+
+ { `${ index + 1 }. ` }
+
+
+
+
+
+ );
+ } ) }
+
+ ) }
+
+
+
+
+ { isLoading && (
+
+
+ { __( 'Searching…', 'hm-query-loop' ) }
+
+ ) }
+
+ { ! isLoading && error && (
+
{ error }
+ ) }
+
+ { ! isLoading &&
+ ! error &&
+ debouncedTerm.length >= 2 &&
+ filteredResults.length === 0 && (
+
+ { __( 'No matching posts found.', 'hm-query-loop' ) }
+
+ ) }
+
+ { ! isLoading && filteredResults.length > 0 && (
+
+ { filteredResults.map( ( result ) => (
+ -
+ addPost( result.id ) }
+ className="hm-query-loop-sticky-result"
+ >
+
+
+
+ ) ) }
+
+ ) }
+
+ { sticky.length > 0 && (
+
onChange( [] ) }
+ className="hm-query-loop-sticky-clear"
+ >
+ { __( 'Unpin all', 'hm-query-loop' ) }
+
+ ) }
+
+ );
+}