◆ 条件を AND/OR で組み合わせ
◆ ネストできる
◆ JSON で取得

条件の組み合わせを画面から作りたいことがときどきあるのですが AND や OR を切り替えたりネストできたりってけっこう複雑です
一回作っておけば 使い回せるかなーと簡単に作ってみました

使い方はこういうのです

<condition-tree>
<condition-option title="equals" element="cond-equals"></condition-option>
<condition-option title="startswith" element="cond-starts-with"></condition-option>
<condition-option title="endswith" element="cond-ends-with"></condition-option>
</condition-tree>

WebComponents を使っていて CustomElement の condition-tree タグを使います
これがネスト可能な AND/OR の構造を作るもので ひとつひとつの条件に使える項目は condition-option タグに指定します
title 属性はセレクトボックスに表示するテキストになります
element 属性はセレクトボックスで選ばれたときに条件を設定するために使う CustomElement 名になります

見た目はこういうのです

conds

青い線が OR で赤い線が AND になっています
クリックで AND と OR は切り替えできます
+ボタンで条件を追加できます

この画像の状態で condition-tree の value を取得するとオブジェクトでこういうデータを取得します

{
"type": "or",
"items": [
{
"name": "equals",
"value": "aa"
},
{
"type": "and",
"items": [
{
"name": "startswith",
"value": "c"
},
{
"name": "endswith",
"value": "d"
}
]
},
{
"name": "equals",
"value": "bb"
}
]
}

name プロパティのあるオブジェクトは element 属性に指定する各条件の CustomElement が返すものです

注意

WebComponents ですがそこまで作り込んでいないので 属性は特にないですし AND/OR の色をカスタムプロパティで変えたりもできません
condition-option を後から変えても更新はしないので condition-tree 自体の再作成が必要です

DEMO

実際に動くコードです
PREVIEW から動かすことができます

<!doctype html>

<condition-tree>
<condition-option title="equals" element="cond-equals"></condition-option>
<condition-option title="startswith" element="cond-starts-with"></condition-option>
<condition-option title="endswith" element="cond-ends-with"></condition-option>
</condition-tree>

<button id="getvalue" style="margin-top: 20px;">get value</button>
<pre></pre>

<script>
customElements.define("condition-tree", class extends HTMLElement {
connectedCallback() {
if (this.shadowRoot) return

this.attachShadow({ mode: "open" })
const ci = document.createElement("condition-item")
const options = Array.from(
this.querySelectorAll("condition-option"),
opt => {
return {
title: opt.getAttribute("title"),
element: opt.getAttribute("element")
}
}
)

ci.options = options
this.shadowRoot.append(ci)
}

get value() {
return this.shadowRoot.children[0].value
}
})

customElements.define("condition-group", class extends HTMLElement {
connectedCallback() {
if (this.shadowRoot) return

this.attachShadow({ mode: "open" }).innerHTML = `
<style>
.root {
display: flex;
padding: 8px;
}
.cond-type {
flex: 0 0 10px;
background: red;
position: relative;
margin-right: 10px;
border-radius: 4px;
}
.cond-type[title=or] {
background: blue;
}
.cond-type button {
position: absolute;
bottom: -10px;
left: -5px;
}
button {
border-radius: 10px;
border: 0;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
width: 20px;
height: 20px;
box-shadow: 0 0 3px 0 #0008;
outline: none;
user-select: none;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
li {
margin: 0;
padding: 10px;
}
</style>
<div class="root">
<div class="cond-type" title="and">
<button name="add">+</button>
</div>
<ul class="conditions"></ul>
</div>
`

const conditions = this.$(".conditions")

this.shadowRoot.addEventListener("click", eve => {
const button_add = eve.target.closest("[name=add]")
if (button_add) {
this.appendCondition()
return
}
const cond_type = eve.target.closest(".cond-type")
if (cond_type) {
cond_type.title = cond_type.title === "and" ? "or" : "and"
return
}
})

this.shadowRoot.addEventListener("request-delete", eve => {
if (eve.target.localName === "condition-item") {
eve.target.remove()

if (conditions.children.length === 1) {
this.replaceWith(conditions.firstElementChild)
}
}
})
}

$(s) {
return this.shadowRoot.querySelector(s)
}

appendCondition(ci) {
const conditions = this.$(".conditions")
ci = ci || document.createElement("condition-item")
ci.options = this.options

conditions.append(ci)
}

get value() {
const conditions = this.$(".conditions")
const cond_type = this.$(".cond-type")
return {
type: cond_type.title,
items: Array.from(conditions.children, x => x.value),
}
}
})

customElements.define("condition-item", class extends HTMLElement {
connectedCallback() {
if (this.shadowRoot) return

this.attachShadow({ mode: "open" }).innerHTML = `
<style>
.root {
display: flex;
padding: 5px 0;
align-items: center;
}
button {
border-radius: 10px;
border: 0;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
width: 20px;
height: 20px;
box-shadow: 0 0 3px 0 #0008;
outline: none;
margin: 0 5px;
user-select: none;
}
condition-selector {
margin-left: 8px;
}
</style>
<div class="root">
<button name="add">+</button>
<button name="delete">✖</button>
</div>
`

this.shadowRoot.addEventListener("click", eve => {
const button_add = eve.target.closest("[name=add]")
if (button_add) {
this.appendSiblingCondition()
}

const button_delete = eve.target.closest("[name=delete]")
if (button_delete) {
this.dispatchEvent(new Event("request-delete", { bubbles: true }))
}
})

const cs = document.createElement("condition-selector")
cs.options = this.options
this.cs = cs
this.shadowRoot.querySelector(".root").append(cs)
}

appendSiblingCondition() {
const cg = document.createElement("condition-group")
cg.options = this.options
const ci = document.createElement("condition-item")
this.replaceWith(cg)
cg.appendCondition(this)
cg.appendCondition(ci)
}

get value() {
return this.cs.value
}
})

customElements.define("condition-selector", class extends HTMLElement {
connectedCallback() {
if (this.shadowRoot) return

this.attachShadow({ mode: "open" }).innerHTML = `
<style>
.root {
display: flex;
align-items: center;
}
select {
margin-right: 10px;
}
</style>
<div class="root">
<select></select>
<div class="container"></div>
</div>
`

const select = this.shadowRoot.querySelector("select")
select.append(
...this.options.map(({ title }, i) => new Option(title, i))
)

select.addEventListener("change", eve => {
this.change(this.options[select.value].element)
})

if (this.options.length) {
this.change(this.options[0].element)
}
}

change(element_name) {
const c = this.shadowRoot.querySelector(".container")
c.innerHTML = ""

const elem = document.createElement(element_name)
this.elem = elem
c.append(elem)
}

get value() {
return this.elem.value
}
})

customElements.define("cond-equals", class extends HTMLElement {
connectedCallback() {
if (this.shadowRoot) return

this.attachShadow({ mode: "open" }).innerHTML = `
<input>
`
}

get value() {
return {
name: "equals",
value: this.shadowRoot.querySelector("input").value
}
}
})

customElements.define("cond-starts-with", class extends HTMLElement {
connectedCallback() {
if (this.shadowRoot) return

this.attachShadow({ mode: "open" }).innerHTML = `
<input>
`
}

get value() {
return {
name: "startswith",
value: this.shadowRoot.querySelector("input").value
}
}
})

customElements.define("cond-ends-with", class extends HTMLElement {
connectedCallback() {
if (this.shadowRoot) return

this.attachShadow({ mode: "open" }).innerHTML = `
<input>
`
}

get value() {
return {
name: "endswith",
value: this.shadowRoot.querySelector("input").value
}
}
})

getvalue.onclick = () => {
getvalue.nextElementSibling.textContent = JSON.stringify(
getvalue.previousElementSibling.value,
null,
"\t"
)
}
</script>