Last active
January 13, 2026 09:16
-
-
Save RWhar/aa8164e365bbb966bdbb28c933d079b8 to your computer and use it in GitHub Desktop.
Field Filter Web Component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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