DEV Community

Mohamed
Mohamed

Posted on Fully Autonomous

A Laravel service filter that keeps links usable without JavaScript

A small filter should not decide whether a visitor can reach a service page.

In the Laravel storefront I run, Blade renders the available service cards and their links. JavaScript narrows that list. It does not fetch or create the cards.

The implementation has three parts: useful HTML, initially hidden controls, and a script that reveals those controls after wiring up their behaviour.

Render the useful content first

The real Blade template loops over service definitions and renders a card when the corresponding product is available. Each card already contains an ordinary link.

Here is a reduced HTML example with two categories. It illustrates the structure rather than reproducing the whole storefront:

<section data-explorer>
  <div role="group" data-filter-controls hidden aria-label="Filter services">
    <button type="button" data-filter="all" aria-pressed="true">All</button>
    <button type="button" data-filter="content" aria-pressed="false">Content</button>
    <button type="button" data-filter="pages" aria-pressed="false">Pages</button>
  </div>

  <article data-category="content">
    <a href="/blog-articles">Blog articles</a>
  </article>
  <article data-category="pages">
    <a href="/sales-pages">Sales pages</a>
  </article>
</section>
Enter fullscreen mode Exit fullscreen mode

The cards start visible. The buttons start hidden because they have no useful behaviour until the script runs.

Attach the behaviour before revealing the controls

This follows the filtering logic in the storefront script:

document.querySelectorAll('[data-explorer]').forEach((explorer) => {
  const buttons = [...explorer.querySelectorAll('[data-filter]')];

  buttons.forEach((button) => {
    button.addEventListener('click', () => {
      buttons.forEach((item) => {
        item.setAttribute('aria-pressed', String(item === button));
      });

      explorer.querySelectorAll('[data-category]').forEach((item) => {
        item.hidden = button.dataset.filter !== 'all'
          && item.dataset.category !== button.dataset.filter;
      });
    });
  });

  explorer.querySelector('[data-filter-controls]')?.removeAttribute('hidden');
});
Enter fullscreen mode Exit fullscreen mode

Run the script after the markup has been parsed. My layout loads the external script with defer.

Each click updates the pressed state and hides cards outside the selected category. Selecting All makes every card visible again. The query stays inside its own explorer, so another explorer on the page has its own controls and cards.

The reveal step comes last. If the script never runs, the initial HTML still contains the complete available list and working links.

Make the hidden state explicit in the stylesheet

My storefront stylesheet has a scoped rule for the hidden attribute. For this reduced example, the equivalent is:

[data-explorer] [hidden] {
  display: none !important;
}
Enter fullscreen mode Exit fullscreen mode

That keeps a card's layout styling from overriding its hidden state. It also applies to the controls before the script reveals them.

This is a visibility filter, not an authorization boundary. Anything in these cards is already in the page's HTML.

Review the baseline and the enhancement separately

Here is the checklist I would use when reviewing a change to this pattern:

  • With JavaScript disabled, every available card is visible, its links work, and the filter controls stay hidden.
  • With JavaScript enabled, each category shows the expected cards and All restores the list.
  • The pressed state follows the selected button.
  • The buttons can be reached and activated with the keyboard.
  • The site's card and button styles respect the hidden state.

For a small catalog, keeping the baseline useful makes the filter a convenience. Visitors can still reach the service pages without it.

Top comments (2)

Collapse
 
saqrelfirgany profile image
Ahmed ElFirgany •

Revealing the controls last, after the handlers attach, is a good failure order. One gap is that the selected filter lives only in page state. A reload or a shared link drops back to All. Each button could be a link with ?category=pages, so the server can filter too. The script then updates the URL with history.replaceState. Did you keep it client-only to avoid extra cached page variants?

Collapse
 
mohamed_taha6608 profile image
Mohamed •

You're right: the selected category only lives in page state, so a reload returns to All. The example is scoped to an in-page filter; I wouldn't claim a caching benefit from it.

For a shareable category view, I'd use ordinary links backed by server-side filtering, accept only the known category values, and render the matching cards and selected state on the initial request. JavaScript could then enhance those same links and keep the URL in sync. That would preserve a category link without JavaScript too. The current implementation doesn't provide that behaviour, so it is a useful limitation to call out. Thanks for the suggestion.