Replies: 6 comments 21 replies
-
Beta Was this translation helpful? Give feedback.
-
Thanks for putting Spark through its paces and for the thorough write-up, @croxton !
Isn’t that what you did in your code? Or do you mean something else? The |
Beta Was this translation helpful? Give feedback.
-
@croxton a little tip for you, assuming you're using PHPstorm... add this file to a <?php
namespace attributes\stores;
use Attribute;
use putyourlightson\spark\models\StoreModel;
#[Attribute]
class FacetedSearch extends StoreModel
{
/** @var string $search The search string */
public string $search;
/** @var array $sizes The array of sizes */
public array $sizes;
/** @var array $colours The arrary of colors */
public array $colours;
} ...and then in your templates put this: {# @var store \attributes\stores\FacetedSearch #} ...and then to get the autoload working for Composer, add this to your "autoload": {
"psr-4": {
"attributes\\": "src/attributes/"
}
}, ...and you'll get autocomplete on your see also: #8 |
Beta Was this translation helpful? Give feedback.
-
For completeness, I refactored the above to encapsulate the history functionality as a web component, following some (but not all*) of Ben's guidelines here (https://putyourlightson.com/plugins/spark#using-javascript-with-spark). Now in theory you can drop <history-sync
data-on-history-restore="$$get('{{ sparkUrl('_shared/spark/search-results.twig') }}')"
data-options="{{ { params: store|keys } | json_encode }}"
></history-sync> *I'm directly accessing store values in Entry template: {% extends "_shared/layouts/base" %}
{# Spark playground - spark.twig #}
{# Get options #}
{% set sizes = craft.entries.section('sizes').all() %}
{% set colours = craft.entries.section('colours').all() %}
{# Register initial param values from the query string. Could use sparkStoreFromClass(). #}
{% set store = {
search: craft.app.request.getQueryParam('search') ?? '',
sizes: craft.app.request.getQueryParam('sizes') ?? '',
colours: craft.app.request.getQueryParam('colours') ? craft.app.request.getQueryParam('colours')|split(',') : []
} %}
{# Output #}
{% block content %}
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/styles/css/multiple-select.min.css" rel="stylesheet">
<style>
:root {
--ms-placeholder-color: black;
}
.ms-choice {
height: 42px;
}
</style>
{# Manage any custom form controls #}
<script type="module">
import multipleSelectVanilla from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';
class CustomFormControls {
constructor() {
this.ms = multipleSelect('#search' + ' select[multiple]', {
selectAll: false,
minimumCountSelected: 0,
placeholder: 'Select colours…'
});
window.addEventListener("history-push", (e) => {
this.ms.refreshOptions({filter: false});
});
}
}
new CustomFormControls();
</script>
{# <history-sync> custom element, syncs store values to url params #}
<script type="module">
class HistorySync extends HTMLElement {
constructor(options = {}) {
super();
this._options = options || {};
}
set options(defaults) {
this._options = {
...this._options,
...defaults,
...JSON.parse(this.dataset.options ?? {}),
};
}
get options() {
return this._options;
}
connectedCallback() {
window.pushHistory = false; // on page load
// set default options
this.options = {
params : []
}
this.updateStore = (e) => {
window.pushHistory = false;
this.updateStoreFromParams();
this.dispatchEvent(new CustomEvent("history-restore"));
}
this.updateStoreHandler = this.updateStore.bind(this);
window.addEventListener("popstate", this.updateStoreHandler);
this.pushHistory = (e) => {
if (window.pushHistory) {
const url = new URL(location);
for (const [key, value] of Object.entries(window.ds.store)) {
if (!key.startsWith('_') && this.options.params.includes(key)) {
url.searchParams.set(key, window.ds.store[key].value);
}
}
history.pushState({}, '', url);
}
window.pushHistory = true;
}
this.pushHistoryHandler = this.pushHistory.bind(this);
window.addEventListener("history-push", this.pushHistoryHandler);
}
disconnectedCallback() {
// destroy global listeners
window.removeEventListener('popstate', this.updateStoreHandler);
this.updateStoreHandler = null;
window.removeEventListener('history-push', this.pushHistoryHandler);
this.pushHistoryHandler = null;
}
updateStoreFromParams() {
let params = new URLSearchParams(location.search);
for (const [key, value] of Object.entries(window.ds.store)) {
if (!key.startsWith('_') && this.options.params.includes(key)) {
if (typeof window.ds.store[key].value === 'string') {
window.ds.store[key].value = params.get(key) || '';
} else if(typeof window.ds.store[key].value === 'object') {
if (params.get(key) === null) {
window.ds.store[key].value = [];
} else {
window.ds.store[key].value = params.get(key).split(',');
}
}
}
}
}
}
customElements.define('history-sync', HistorySync);
</script>
<history-sync
data-on-history-restore="$$get('{{ sparkUrl('_shared/spark/search-results.twig') }}')"
data-options="{{ { params: store|keys } | json_encode }}"
></history-sync>
<section id="search"
class="p-12 space-y-6"
data-store="{{ store | json_encode }}">
<h2 class="text-xl">Artist T-shirts</h2>
{# Search filters #}
<div class="flex gap-8">
{# Search phrase #}
<div class="w-56">
<label for="search" class="block mb-1">Search</label>
<input
id="search"
name="search"
data-model="search"
data-on-input.debounce_500ms="$$get('{{ sparkUrl('_shared/spark/search-results.twig') }}')"
placeholder="Search..."
type="text"
class="w-full appearance-none px-3 py-2 border border-gray-300 rounded-sm"
/>
</div>
{# Sizes #}
<div class="w-56">
<label for="sizes" class="block mb-1">Size</label>
<select id="sizes"
name="sizes"
data-model="sizes"
data-on-change="$$get('{{ sparkUrl('_shared/spark/search-results.twig') }}')"
class="w-full appearance-none px-3 py-2 border border-gray-300 rounded-sm">
<option value="">Select size…</option>
{% for option in sizes %}
<option value="{{ option.id }}">
{{ option.title }}
</option>
{% endfor %}
</select>
</div>
{# Colours #}
<div class="w-56">
<label for="colours" class="block mb-1">Colours</label>
<div>
<select multiple
id="colours"
data-model="colours"
data-on-change="$$get('{{ sparkUrl('_shared/spark/search-results.twig') }}')"
name="colors[]"
class="w-full">
{% for option in colours %}
<option value="{{ option.id }}">
{{ option.title }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
{# Reset search params #}
<p>
<button type="button"
class="underline text-sm"
data-on-click="$search='';$sizes='';$colours=[];$$get('{{ sparkUrl('_shared/spark/search-results.twig') }}')"
>
Reset search
</button>
</p>
{# Search results listing #}
<h2 class="text-xl pt-4 border-t">Search results</h2>
{% include '_shared/spark/search-results.twig' with { store } %}
</section>
{% endblock %} The fragment: {# _spark/search-results.twig #}
<div id="search-results"
role="region"
aria-live="polite"
data-on-load="window.dispatchEvent(new CustomEvent('history-push'))">
{% set query = craft.entries().section('shirts').limit(9).orderBy('score') %}
{# Look for a search term #}
{% if store.search is not empty %}
{% do query.search(store.search) %}
{% endif %}
{# Look for a size #}
{% if store.sizes is not empty %}
{% do query.andRelatedTo({
field: 'relatedSizes',
targetElement: store.sizes
}) %}
{% endif %}
{# Look for one or more colours #}
{% if store.colours is iterable and store.colours|filter is not empty %}
{% do query.andRelatedTo({
field: 'relatedColours',
targetElement: store.colours
}) %}
{% endif %}
{% set results = query.all() ?? [] %}
<div class="p-8 bg-yellow-100 space-y-6">
<p class="font-bold">{{ results|length }} results</p>
<ul>
{% for result in results %}
<li><a href="{{ result.url }}" class="underline">{{ result.title }}</a></li>
{% else %}
<p>No results</p>
{% endfor %}
</ul>
</div>
<div class="bg-gray-100 p-3 text-sm mt-8 space-y-2">
<h3 class="font-bold">Store values</h3>
<p>
Keywords: <span data-text="$search"></span><br>
Size: <span data-text="$sizes"></span><br>
Colour: <span data-text="$colours"></span><br>
</p>
</div>
</div> |
Beta Was this translation helpful? Give feedback.
-
Another thought that did occur to me was how to best import and initialise Datastar itself inside a custom element, if, say, you wanted to distribute a dashboard, data visualisation, map or other kind of standalone widget encapsulated in a web component. |
Beta Was this translation helpful? Give feedback.
-
To get to grips with Spark / Datastar, I wanted to implement a drill-down style faceted search interface of the kind you might find on a shopping website. It's something I've often had to build over the years on various platforms, but most recently I've been using Sprig for it. Despite appearances it's not easy to get the mechanics right:
<select multiple>
are unusable on desktop browsers, you may need a custom datepicker or range control. It's tempting to implement your own, but you risk running into issues around accessibility, keyboard control and focus state unless you test extensively.As @khalwat had kindly already created an autocomplete search form, I started with that. I wasn't able to solve all of the requirements above but I got far enough to believe that it should be possible.
Heres what I ended up with:
spark-demo2.mp4
The entry template looks like this:
And the fragment it requests
_shared/spark/search-results.twig
looks like this:How it works
The first thing to note is that the Craft template includes the Spark fragment directly, and passes store values from URL parameters to it. And vice-versa, I update the URL with
pushState()
whenever Datastar updates store values.That means that the every possible UI state can be represented by an addressable URI that can be shared or bookmarked and will work with browser history navigation and the bfcache, for scroll position restoration.
data-on-change
listener on a form control is triggered when it's value changes and causes Datastar to request the fragment with an updated set of parameters. This fragment is rendered by Craft and Datastar swaps it into the matching div in the main template (<div id="search-results">
) via an Ideomorph swap.data-on-load
listener in the fragment is triggered and calls a function to update the URL, so that the query string it corresponds to the store values.popstate
listener onwindow
is triggered and in turn emits a customEventrestore
which is picked up by thedata-on-restore
listener on the main search div (<div id="search">
); this updates the store with parameter values retrieved from the restored URL.Custom form control
Since
<select multiple>
is a disaster, I opted to use Multiple-Select-Vanilla to progressively enhance that form control. It has a handyrefreshOptions()
method that can be used to refresh the control when the native select options change, without affecting focus / tab order etc. While I didn't implement the narrowing of options while the user drills down, I was able to establish that this will be possible.Possible improvements:
Datastar doesn't have a built in way to map query string params to the store and back, so that bit feels a little awkward. I'd love to see some official support for that - and the history api - but I won't my breath! 😆
Spark doesn't have a way to embed parts of a template, but maybe it should since I can foresee the need to do that if I'm not just swapping one
<div>
, but want to have the fragment be rendered directly in the template on page load (rather than being triggered withdata-on-load
). Possibly the{% fragment %}
syntax from an earlier version could work, maybe coupled with an ID?I realise that Datastar / Spark's ambition is to replace the need for other frameworks and js libraries, but I don't think that is realistic given the possible pitfalls of rolling-your-own. Web components may be part of the answer, but there are many other libraries that we would like to use in various contexts that might need to interact with Datastar, and it could benefit from being a "good citizen".
Spark API: I prefer the more verbose syntax, it's closer to the original.
Conclusion
This was a fun project. Spark / Datastar feels like it could evolve to be a significant improvement on htmx / Sprig, which has always felt a little byzantine to me. It's great to have explicit fragments that you can swap into parts of the page without having to figure out
oob
stuff, and the two-way binding is a revelation. I'm excited to see where it goes!Beta Was this translation helpful? Give feedback.
All reactions