Skip to content

Instantly share code, notes, and snippets.

@RWhar
Last active January 13, 2026 09:16
Show Gist options
  • Select an option

  • Save RWhar/aa8164e365bbb966bdbb28c933d079b8 to your computer and use it in GitHub Desktop.

Select an option

Save RWhar/aa8164e365bbb966bdbb28c933d079b8 to your computer and use it in GitHub Desktop.
Field Filter Web Component
<!DOCTYPE html>
<html lang="en">
<head>
<style>
:root {
--font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
--foreground-color: #fff;
--muted-foreground-color: #f7f7f7;
--form-background-color: #f0f0f9;
--foreground-text-color: ;
--background-text-color: #ccc;
--page-background: #d9d9d9;
--muted-border-color: #eee;
}
.dark-mode {
--foreground-color: #333;
--muted-foreground-color: #666;
--form-background-color: #26272a;
--foreground-text-color: #f7f7f7;
--background-text-color: #ccc;
--page-background: #000;
--muted-border-color: #606666;
}
body {
font-family: var(--font-family);
background-color: var(--page-background);
color: var(--background-text-color);
}
form {
max-width: 1100px;
}
form .row {
display: flex;
width: 100%;
}
form .row:has([folded]) {
width: auto;
}
form .row .left,
form .row .right
{
flex-basis: 50%;
}
form .footer {
margin-top: 4px;
display: flex;
padding: 10px 0;
}
form .footer > * {
margin: 0 10px;
flex-wrap: wrap;
row-gap: 10px;
column-gap: 2em;
}
form .footer .left {
flex-basis: 70%;
flex: 1;
text-align: left;
}
form .footer .right {
margin-left: auto;
margin-top: auto;
margin-bottom: auto;
align-content: end;
}
.form-fields {
background-color: var(--form-background-color);
padding: 5px 5px 10px 5px;
margin-bottom: 3px;
border-radius: 5px;
width: 95%;
box-shadow: 2px 2px 5px #55555533;
}
form .query-button {
padding: 4px;
cursor: pointer;
background-color: var(--form-background-color);
border: 1px solid var(--muted-border-color);
border-radius: 3px;
color: var(--foreground-text-color);
font-size: 0.70em;
padding: 6px;
letter-spacing: 0.05em;
}
form .query-button:hover {
background-color: var(--foreground-color);
border-color: #000;
}
.footer .info {
font-size: 0.60em;
}
field-filter {
display: inline-block;
}
field-filter:not([folded]).full {
width: 100%;
}
field-filter[folded] {
width: 20%;
float: left;
flex-basis: 20% !important;
}
</style>
</head>
<script type="module">
const stylesheet = new CSSStyleSheet();
stylesheet.replaceSync(`
:host {
--background-color: #f7f7f7;
--foreground-color: #fff;
--foreground-text-color: #000;
--background-text-color: #666;
--muted-foreground-text-color: #ccc;
--muted-background-text-color: #333;
--list-item-color: #f2f2f2;
--border-color: #ccc;
--background-icon-color: #999;
}
.dark-mode {
--background-color: #333;
--foreground-color: #181921;
--foreground-text-color: #fff;
--background-text-color: #666;
--muted-foreground-text-color: #ccc;
--muted-background-text-color: #666;
--list-item-color: #333;
--border-color: #606666;
--background-icon-color: #999;
}
div {
font-family: var(--font-family);
width: 100%;
display: inline-block;
position: relative;
margin-bottom: 4px;
}
legend {
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 3px 5px;
background-color: var(--foreground-color);
letter-spacing: 0.05em;
color: var(--foreground-text-color);
font-size: 0.6em;
text-transform: uppercase;
font-weight: bold;
}
legend button {
color: var(--muted-foreground-text-color);
background-color: transparent;
border: none;
font-size: 0.8em;
}
fieldset {
display: flex;
flex-wrap: wrap;
border: 1px solid var(--border-color);
border-radius: 3px;
background-color: var(--foreground-color);
padding-top: 0;
padding-bottom: 6px;
}
#multi {
display: contents;
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: inline;
display: contents;
}
li {
display: inline-block;
background-color: var(--list-item-color);
color: var(--foreground-text-color);
border: 1px solid transparent;
border-radius: 5px;
padding: 3px 5px;
margin-right: 2px;
margin-bottom: 2px;
cursor: default;
}
li:hover {
border: 1px solid var(--border-color);
}
li .remove-item:hover {
color: red;
}
:host[folded] fieldset {
width: 20%;
float: left;
}
:host([folded]) input,
:host([folded]) ul,
:host([folded]) select {
display: none;
}
select {
flex-shrink: 1;
display: inline-block;
margin: 4px 0;
border: none;
border-radius: 3px;
padding: 2px;
background-color: var(--foreground-color);
cursor: pointer;
color: var(--muted-foreground-text-color);
}
select:hover {
color: var(--foreground-text-color);
}
input[type='text'] {
flex-grow: 1;
flex-shrink: 1;
border: none;
outline: none;
padding: 5px;
font-size: 14px;
background-color: transparent;
color: var(--foreground-text-color);
}
input[type='text']:focus {
outline: none;
}
input[type='text']::placeholder {
color: var(--muted-background-text-color);
}
.remove-item {
background-color: transparent;
border: none;
color: var(--background-icon-color);
cursor: pointer;
margin-left: 5px;
font-size: 0.6em;
vertical-align: middle;
margin-bottom: 4px;
}
`);
export default class FieldFilter extends HTMLElement {
static formAssociated = true;
constructor() {
super();
// TODO: Use window.location.href to prefil element value(s)?
this.shadow = this.attachShadow({ mode: "open" }).adoptedStyleSheets = [stylesheet];
this.internals = this.attachInternals();
this.operators = 'any,all';
if (this.hasAttribute('operators')) {
this.operators = this.getAttribute('operators');
}
const name = this.getAttribute('name');
const operatorSelect = this._getOperators().outerHTML;
// TODO: Move from UL to divs to allow flex grid to function properly
const html = `
<div class="dark-mode">
<fieldset>
<legend>${name.replace('_', ' ')}<button type="button">˅</button></legend>
${operatorSelect}
<ul></ul>
<input name="criteria" type="text" placeholder="add criteria..." />
</fieldset>
</div>
`;
this.shadowRoot.innerHTML = html;
this.operatorElement = this.shadowRoot.querySelector('select');
this.itemListElement = this.shadowRoot.querySelector('ul');
}
static get observedAttributes() {
return ['name', 'operators', 'operator', 'criteria-items'];
}
attributeChangedCallback(property, oldValue, newValue) {
if (oldValue === newValue) return;
this[property] = newValue;
if (property === 'operator' && this.operatorElement) {
this.operatorElement.value = newValue;
}
if (property === 'criteria-items') {
const items = newValue.split(',');
console.log(items);
items.forEach((item) => { this.addItem(this.itemsElement, item) });
}
}
connectedCallback() {
const criteria = this.shadowRoot.querySelector('input[name="criteria"]');
const operator = this.shadowRoot.querySelector('select[name="operator"]');
const fold = this.shadowRoot.querySelector('legend > button');
const multi = this.shadowRoot.getElementById('multi');
this.internals.setFormValue(this._getFormData());
criteria.addEventListener('change', () => {
// TODO: Add two-way bind with this.setAttribute('criteraItems', this.getAllCriteriaItems())
this.internals.setFormValue(this._getFormData());
});
operator.addEventListener('change', () => {
// TODO: Add two-way bind with this.setAttribute('operator', ...)
this.internals.setFormValue(this._getFormData());
});
fold.addEventListener('click', (event) => this.fold(event));
this.init();
}
_getFormData() {
const criteria = this.shadowRoot.querySelector('input[name="criteria"]');
const operator = this.shadowRoot.querySelector('select[name="operator"]');
const itemList = [];
if (criteria.value) {
itemList.push(criteria.value)
}
const additionalItems = this.shadowRoot.querySelector('ul li');
if (additionalItems) {
itemList.push(...this.fetchField());
}
if (itemList.length === 0) {
return new FormData();
}
const formData = new FormData();
formData.append(this.name + '-operator', operator.value);
itemList.forEach((item) => formData.append(this.name + '-criteria[]', item));
return formData;
}
_getOperators() {
const operatorsAttrib = this.operators;
const options = document.createElement('select');
options.name="operator";
options.classList.add('operator');
if (!operatorsAttrib) {
return options;
}
const operators = operatorsAttrib.split(',');
operators.forEach(function (operator) {
const option = document.createElement('option');
option.innerHTML = operator;
options.appendChild(option);
});
return options;
}
init() {
const items = this.shadowRoot.querySelector('ul');
const input = this.shadowRoot.querySelector('input[name="criteria"]');
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
const itemText = input.value.trim();
if (itemText !== '') {
this.addItem(items, itemText);
input.value = '';
this.internals.setFormValue(this._getFormData());
}
}
});
items.addEventListener('click', (event) => {
if (event.target.classList.contains('remove-item')) {
event.target.parentNode.remove();
this.internals.setFormValue(this._getFormData());
}
if (event.target.classList.contains('fold')) {
console.log(event.target.parentNode.tagName);
}
});
}
addItem(itemsListElement, itemText) {
const item = document.createElement('li');
item.innerText = itemText;
item.innerHTML += '<button type="button" class="remove-item">🗙</button>';
this.itemListElement.appendChild(item);
}
fetchField(ignoreEmpty = true) {
const itemElements = this.shadowRoot.querySelectorAll('ul li');
if (! itemElements) {
return [];
}
const itemCollection = Array.from(itemElements);
let criteriaStrings = [];
itemCollection.forEach((criteria) => {
criteriaStrings.push(criteria.childNodes[0].textContent);
});
if (criteriaStrings.length === 0 && ignoreEmpty === true) {
return [];
}
return criteriaStrings;
}
get folded() {
return this.internals.states.has("folded");
}
fold(event) {
const foldButton = event.target;
const target = event.target.parentNode.parentNode;
this.toggleAttribute('folded');
let foldText = '˅';
if (this.hasAttribute("folded")) {
foldText = '˃';
}
foldButton.innerHTML = foldText;
}
}
customElements.define("field-filter", FieldFilter);
</script>
<body class="dark-mode">
<form method="GET" id="tf2">
<div class="form-fields">
<field-filter class="full" name="topic_name" operators="contains,begins,ends" operator="ends" criteria-items="one,two,three"></field-filter>
<field-filter class="full" name="target"></field-filter>
<div class="row">
<field-filter class="left" name="filter_policy.resource"></field-filter>
<field-filter class="right" name="type"></field-filter>
</div>
<div class="footer">
<div class="left">
<p class="info">Results: 0 (Total: 52)</p>
</div>
<div class="right">
<button type="submit" class="query-button">Execute</button>
</div>
</div>
</div>
</form>
<script>
document.getElementById("tf2").addEventListener('submit', function (event) {
// event.preventDefault();
console.log(Object.fromEntries(new FormData(event.target)));
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment