diff --git a/.npmrc b/.npmrc index 377f45d5b..b6f27f135 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1 @@ engine-strict=true -node-options='--no-experimental-fetch' diff --git a/src/baklava.ts b/src/baklava.ts index a3813b839..0900b8932 100644 --- a/src/baklava.ts +++ b/src/baklava.ts @@ -1,4 +1,3 @@ -export { html as litHtmlRenderer } from "lit"; export { default as BlAccordion } from "./components/accordion-group/accordion/bl-accordion"; export { default as BlAccordionGroup } from "./components/accordion-group/bl-accordion-group"; export { default as BlAlert } from "./components/alert/bl-alert"; @@ -41,6 +40,7 @@ export { default as BlTableHeader } from "./components/table/table-header/bl-tab export { default as BlTableRow } from "./components/table/table-row/bl-table-row"; export { default as BlTag } from "./components/tag/bl-tag"; export { default as BlTextarea } from "./components/textarea/bl-textarea"; +export { default as BlTreeSelect, type TreeNode } from "./components/tree-select/bl-tree-select"; export { default as BlTooltip } from "./components/tooltip/bl-tooltip"; export { getIconPath, setIconPath } from "./utilities/asset-paths"; export { getLocale, setLocale } from "./localization"; diff --git a/src/components/tree-select/bl-tree-select.css b/src/components/tree-select/bl-tree-select.css new file mode 100644 index 000000000..cbd14f8bc --- /dev/null +++ b/src/components/tree-select/bl-tree-select.css @@ -0,0 +1,269 @@ +:host { + --tree-select-border-color: var(--bl-color-neutral-lighter); + --tree-select-focus-border-color: var(--bl-color-primary-highlight); + --tree-select-bg: var(--bl-color-neutral-full); + --bl-tree-select-wrapper-width: 420px; + --bl-popover-padding: 0; + + display: inline-block; + width: var(--bl-tree-select-wrapper-width); +} + +.tree-select-wrapper { + display: flex; + flex-direction: column; + gap: var(--bl-size-4xs); + position: relative; +} + +.tree-select-wrapper .header { + display: flex; + gap: var(--bl-size-4xs); + align-items: center; +} + +.tree-select-label { + font: var(--bl-font-caption); + color: var(--bl-color-neutral-dark); + display: block; +} + +.tree-select-wrapper .required-suffix { + color: var(--bl-color-danger); +} + +/* Trigger (input wrapper) */ +.tree-select-trigger { + display: flex; + align-items: center; + gap: var(--bl-size-3xs); + padding: var(--bl-size-3xs) var(--bl-size-xs); + border: 1px solid var(--tree-select-border-color); + border-radius: var(--bl-border-radius-s); + background-color: var(--tree-select-bg); + cursor: pointer; + outline: none; + transition: border-color 0.15s ease; + min-height: 40px; +} + +.tree-select-trigger:hover { + border-color: var(--bl-color-primary); +} + +.tree-select-trigger:focus-visible, +.tree-select-trigger-focused { + border-color: var(--tree-select-focus-border-color); +} + +.tree-select-trigger-has-value .tree-select-input { + color: var(--bl-color-neutral-darker); +} + +.tree-select-input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + font: var(--bl-font-body-text-2-regular); + color: var(--bl-color-neutral-light); + cursor: inherit; + padding: 0; +} + +.tree-select-input::placeholder { + color: var(--bl-color-neutral-light); +} + +.tree-select-trigger-has-value .tree-select-input::placeholder { + color: var(--bl-color-neutral-darker); +} + +.tree-select-loading { + flex-shrink: 0; +} + +.tree-select-clear { + flex-shrink: 0; +} + +.tree-select-chevron { + flex-shrink: 0; + font-size: var(--bl-font-size-m); + color: var(--bl-color-neutral-dark); + transition: transform 0.2s ease; +} + +.tree-select-chevron-open { + transform: rotate(180deg); +} + +:host([disabled]) .tree-select-trigger { + pointer-events: none; + opacity: 0.6; + cursor: not-allowed; +} + +/* Panel inside bl-popover slot */ +.tree-select-panel { + display: flex; + flex-direction: column; + min-width: 312px; + max-height: 320px; + overflow: hidden; + padding: var(--bl-size-s); + outline: none; +} + +.tree-select-panel:focus { + outline: none; +} + +.select-all-row { + padding: var(--bl-size-2xs) 0; + margin-bottom: var(--bl-size-2xs); + border-radius: var(--bl-border-radius-xs); +} + +.select-all-row-focused, +.tree-node-row-focused { + border-radius: var(--bl-border-radius-xs); +} + +.tree-node-row-focused .tree-node-label { + color: var(--bl-color-primary); +} + +.select-all-row-focused bl-checkbox, +.select-all-row-focused .select-all-count { + color: var(--bl-color-primary); +} + +.select-all-checkbox { + width: 100%; +} + +.select-all-count { + font: var(--bl-font-body-text-3-regular); + color: var(--bl-color-primary); +} + +.tree-list { + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.tree-node { + padding-inline-start: 0; +} + +.tree-node-row { + display: flex; + align-items: center; + gap: var(--bl-size-2xs); + min-height: 36px; + padding-inline-start: calc(var(--depth, 0) * var(--bl-size-xl)); +} + +.tree-node-row .tree-node-count { + margin-inline-start: auto; + flex-shrink: 0; +} + +.tree-node-single-parent .tree-node-row .tree-node-label { + flex: 1; + min-width: 0; +} + +.tree-node-single-parent .tree-node-row { + cursor: default; +} + +.tree-expand { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--bl-size-m); + height: var(--bl-size-m); + flex-shrink: 0; + cursor: pointer; + color: var(--bl-color-neutral-darker); + border-radius: var(--bl-border-radius-xs); +} + +.tree-expand:hover { + background-color: var(--bl-color-neutral-lightest); +} + +.tree-expand bl-icon { + font-size: var(--bl-font-size-s); + color: var(--bl-color-neutral-darker); +} + +.tree-expand-spacer { + display: inline-block; + width: var(--bl-size-m); + height: var(--bl-size-m); +} + +.tree-checkbox { + flex: 1; + min-width: 0; +} + +.tree-node-label { + font: var(--bl-font-body-text-2-regular); + color: var(--bl-color-neutral-darker); +} + +.tree-node-selected .tree-node-label { + color: var(--bl-color-primary); +} + +.tree-node-count { + font: var(--bl-font-body-text-3-regular); + color: var(--bl-color-neutral-dark); +} + +/* Autocomplete list (yazarken path listesi) */ +.autocomplete-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.autocomplete-row { + display: flex; + align-items: center; + min-height: 36px; + padding: var(--bl-size-2xs) var(--bl-size-2xs); + border-radius: var(--bl-border-radius-xs); + cursor: pointer; + font: var(--bl-font-body-text-2-regular); + color: var(--bl-color-neutral-darker); +} + +.autocomplete-row:hover { + background-color: var(--bl-color-neutral-lightest); +} + +.autocomplete-row.tree-node-row-focused .autocomplete-path { + color: var(--bl-color-primary); +} + +.autocomplete-path { + flex: 1; + min-width: 0; +} + +.autocomplete-match { + color: var(--bl-color-primary); + font-weight: 600; +} + +.autocomplete-empty { + font: var(--bl-font-title-3-regular); + color: var(--bl-color-neutral-dark); +} diff --git a/src/components/tree-select/bl-tree-select.stories.mdx b/src/components/tree-select/bl-tree-select.stories.mdx new file mode 100644 index 000000000..9c26e1a63 --- /dev/null +++ b/src/components/tree-select/bl-tree-select.stories.mdx @@ -0,0 +1,309 @@ +import { Meta, Canvas, Story, ArgsTable } from "@storybook/addon-docs"; +import { html } from "lit"; +import { centeredLayout } from "../../utilities/chromatic-decorators"; + + + +export const sampleTree = [ + { + value: "parent-1", + label: "Parent node", + count: 5, + children: [ + { + value: "child-1", + label: "Child node one", + count: 4, + children: [ + { value: "sibling-1", label: "Sibling node one" }, + { value: "sibling-2", label: "Sibling node two" }, + { + value: "sibling-tree", + label: "Sibling node tree", + count: 2, + children: [ + { value: "leaf-1", label: "Leaf node one" }, + { value: "leaf-2", label: "Leaf node two" }, + { value: "sibling-4", label: "Sibling node four" }, + ], + }, + ], + }, + { value: "child-2", label: "Child node two", count: 24 }, + { value: "child-tree", label: "Child node tree", count: 24 }, + ], + }, + { + value: "parent-2", + label: "Parent node", + count: 8, + children: [], + }, + { + value: "parent-3", + label: "Parent node", + count: 64, + children: [], + }, + { + value: "parent-4", + label: "Parent node", + count: 32, + children: [], + }, + { + value: "parent-5", + label: "Parent node", + count: 6, + children: [], + }, +]; + +export const DefaultTemplate = (args) => html` + +`; + +# Tree Select + +[ADR](https://github.com/Trendyol/baklava/issues/1125) +[Figma](https://www.figma.com/design/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?node-id=26640-5823&p=f&t=FjujjxTaCNkGYHd4-0) + +The Tree Select component allows for selection within hierarchical data structures (such as a category tree). Users can filter by typing in the search box with the autocomplete feature and can make single or multiple selections from the results list. This component is particularly used in areas requiring category selection, product classification, and nested taxonomy. + +## Anatomy + +It supports single- and multi-select patterns, “Select All” headers, item counts, and indeterminate states for partially-selected branches. + +### Input Field + +- The autocomplete dropdown becomes active when typing begins. +- As text is entered, results are displayed with a path representation (e.g., Clothing / Suit). + + + + {DefaultTemplate.bind()} + + + +### Dropdown Panel + +- Displays the hierarchical structure. +- Each level can be expanded with a clickable (>) icon. +- When a subcategory is selected, the parent categories become disabled or enter a half-selected state. +- Minimum width is 312 px. Widths above this can be used. + + + + {DefaultTemplate.bind()} + + + +### Focused / Typing (autocomplete) + +When the user types in the input, the dropdown shows a filtered list of category paths. The matching part of each path is highlighted in orange. Keyboard focus is on the first suggestion; use ↑/↓ to move, Enter or click to select. + + + + {DefaultTemplate.bind()} + + + +### Focused + +Keyboard focus lands on a node (or Select All), with a visible focus ring. Use the panel’s keyboard shortcuts: + +- **↑ / ↓** — Move between visible items +- **←** — Collapse expanded node +- **→** — Expand collapsed node +- **Space / Enter** — Toggle selection + + + + {DefaultTemplate.bind()} + + + +### Selected + +One or more items have been selected. The input shows the selection and a clear (X) button to remove it. + + + + {DefaultTemplate.bind()} + + + +### Empty + +When the search has no matching results, an empty state message is shown with an orange border (e.g. type a term that does not match any category). + + + + {DefaultTemplate.bind()} + + + +### Disabled + +The component is dimmed, controls are inactive, and the cursor shows not-allowed. + + + + {DefaultTemplate.bind()} + + + +### Selected State (multiple) + +The selected items are displayed within the input. + + + + {DefaultTemplate.bind()} + + + +### Single select + +When `isMultiple` is false, the tree has no checkboxes and no "Select All". Click a row to select that single item; the selected row is highlighted and its label is shown in the input. + + + + {DefaultTemplate.bind()} + + + +### Single – Selected State + +Single mode with a default selected item (e.g. "Sibling node one" highlighted in the dropdown). + + + + {DefaultTemplate.bind()} + + + + diff --git a/src/components/tree-select/bl-tree-select.test.ts b/src/components/tree-select/bl-tree-select.test.ts new file mode 100644 index 000000000..2242e64d7 --- /dev/null +++ b/src/components/tree-select/bl-tree-select.test.ts @@ -0,0 +1,1485 @@ +import { assert, elementUpdated, expect, fixture, html, oneEvent } from "@open-wc/testing"; +import { render } from "lit"; +import type { TemplateResult } from "lit"; +import { sendKeys } from "@web/test-runner-commands"; +import BlTreeSelect from "./bl-tree-select"; +import type { TreeNode } from "./bl-tree-select"; + +const sampleTree: TreeNode[] = [ + { + value: "p1", + label: "Parent 1", + count: 2, + children: [ + { value: "c1", label: "Child 1", count: 1 }, + { value: "c2", label: "Child 2" }, + ], + }, + { value: "p2", label: "Parent 2", count: 0, children: [] }, +]; + +/** bl-checkbox input is inside its shadowRoot */ +function getCheckboxInput(host: Element, selector: string): HTMLInputElement | null { + const blCheckbox = host.shadowRoot?.querySelector(selector); + + if (!blCheckbox || !("shadowRoot" in blCheckbox)) return null; + return (blCheckbox as Element & { shadowRoot: ShadowRoot }).shadowRoot.querySelector("input"); +} + +/** Get the search input from tree-select's shadow root */ +function getSearchInput(host: BlTreeSelect): HTMLInputElement | null { + return host.shadowRoot?.querySelector(".tree-select-input") ?? null; +} + +/** Get the clear button from tree-select's shadow root */ +function getClearButton(host: BlTreeSelect): HTMLElement | null { + return host.shadowRoot?.querySelector(".tree-select-clear") as HTMLElement | null; +} + +/** Get the trigger element from tree-select's shadow root */ +function getTrigger(host: BlTreeSelect): HTMLElement | null { + return host.shadowRoot?.querySelector(".tree-select-trigger") as HTMLElement | null; +} + +describe("bl-tree-select", () => { + it("is defined", () => { + const el = document.createElement("bl-tree-select"); + + assert.instanceOf(el, BlTreeSelect); + }); + + it("renders with default values", async () => { + const el = await fixture(html``); + + expect(el.shadowRoot?.querySelector(".tree-select-wrapper")).to.exist; + expect(getTrigger(el)).to.exist; + expect(getSearchInput(el)).to.exist; + expect(el.label).to.equal(""); + expect(el.placeholder).to.equal(""); + expect(el.isMultiple).to.be.true; + expect(el.value).to.be.null; + }); + + it("renders label and required suffix when set", async () => { + const el = await fixture( + html`` + ); + + const label = el.shadowRoot?.querySelector(".tree-select-label"); + + expect(label?.textContent?.trim()).to.equal("Category"); + expect(el.shadowRoot?.querySelector(".required-suffix")?.textContent).to.include("*"); + }); + + it("renders label without required suffix when required is false", async () => { + const el = await fixture( + html`` + ); + + const requiredSuffix = el.shadowRoot?.querySelector(".required-suffix"); + + expect(requiredSuffix?.textContent?.trim()).to.equal(""); + }); + + it("renders placeholder on input", async () => { + const el = await fixture( + html`` + ); + + const input = getSearchInput(el); + + expect(input?.placeholder).to.equal("Select..."); + }); + + it("opens panel on click and closes on Escape", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.exist; + expect(el.shadowRoot?.querySelector(".tree-select-panel")).to.exist; + + const trigger = getTrigger(el); + + trigger?.focus(); + await sendKeys({ press: "Escape" }); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.not.exist; + }); + + it("open() shows panel and close() hides it", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.exist; + + el.close(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.not.exist; + }); + + it("does not open when disabled", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.not.exist; + }); + + it("renders tree when opened with items", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const treeList = el.shadowRoot?.querySelector(".tree-list"); + + expect(treeList).to.exist; + expect(treeList?.querySelectorAll(".tree-node").length).to.be.greaterThan(0); + expect(treeList?.querySelector(".tree-node-label")?.textContent?.trim()).to.equal("Parent 1"); + }); + + it("reflects value as single string and selectedSet", async () => { + const el = await fixture( + html`` + ); + + expect(el.value).to.equal("c1"); + expect(el.selectedSet.has("c1")).to.be.true; + expect(el.selectedSet.size).to.equal(1); + }); + + it("reflects value as array and selectedSet", async () => { + const el = await fixture( + html`` + ); + + expect(el.value).to.deep.equal(["c1", "c2"]); + expect(el.selectedSet.has("c1")).to.be.true; + expect(el.selectedSet.has("c2")).to.be.true; + }); + + it("dispatches bl-tree-select-change when selection changes via checkbox", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const input = getCheckboxInput(el, ".tree-node .tree-checkbox"); + + const promise = oneEvent(el, "bl-tree-select-change"); + + setTimeout(() => input?.click()); + const ev = await promise; + + expect(ev.detail?.value).to.be.an("array"); + expect((ev.detail?.value as string[]).length).to.be.greaterThan(0); + }); + + it("clear button clears selection and dispatches change", async () => { + const el = await fixture( + html`` + ); + + const clearBtn = getClearButton(el); + + expect(clearBtn).to.exist; + const promise = oneEvent(el, "bl-tree-select-change"); + + clearBtn!.click(); + const ev = await promise; + + expect(ev.detail?.value).to.deep.equal([]); + expect(el.value).to.deep.equal([]); + }); + + it("_clearSelection sets value to null in single mode", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const clearSelection = (el as unknown as { _clearSelection(): void })._clearSelection.bind(el); + const promise = oneEvent(el, "bl-tree-select-change"); + + clearSelection(); + await promise; + expect(el.value).to.be.null; + }); + + it("does not show clear button when disabled", async () => { + const el = await fixture( + html`` + ); + + expect(getClearButton(el)).to.not.exist; + }); + + it("shows Select All row when viewSelectAll and isMultiple", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".select-all-row")).to.exist; + expect(el.shadowRoot?.querySelector(".select-all-checkbox")?.textContent).to.include( + "Select All" + ); + }); + + it("Select All checkbox toggles all visible nodes", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const selectAllInput = getCheckboxInput(el, ".select-all-row .select-all-checkbox"); + + const promise1 = oneEvent(el, "bl-tree-select-change"); + + setTimeout(() => selectAllInput?.click()); + await promise1; + await elementUpdated(el); + expect(el.selectedSet.size).to.be.greaterThan(0); + + const promise2 = oneEvent(el, "bl-tree-select-change"); + + setTimeout(() => selectAllInput?.click()); + await promise2; + await elementUpdated(el); + expect(el.selectedSet.size).to.equal(0); + }); + + it("unchecking parent node removes node and all descendants from selection (_toggleNode else)", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const parent1Checkbox = getCheckboxInput(el, '[data-tree-focus="p1"] .tree-checkbox'); + + const promise1 = oneEvent(el, "bl-tree-select-change"); + + setTimeout(() => parent1Checkbox?.click()); + await promise1; + await elementUpdated(el); + expect(el.selectedSet.has("p1")).to.be.true; + expect(el.selectedSet.has("c1")).to.be.true; + expect(el.selectedSet.has("c2")).to.be.true; + + const promise2 = oneEvent(el, "bl-tree-select-change"); + + setTimeout(() => parent1Checkbox?.click()); + await promise2; + await elementUpdated(el); + expect(el.selectedSet.has("p1")).to.be.false; + expect(el.selectedSet.has("c1")).to.be.false; + expect(el.selectedSet.has("c2")).to.be.false; + }); + + it("single mode: only leaf nodes have checkbox", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const parentRow = el.shadowRoot?.querySelector(".tree-node-single-parent"); + + expect(parentRow).to.exist; + expect(parentRow?.querySelector("bl-checkbox")).to.not.exist; + expect(parentRow?.querySelector(".tree-node-label")?.textContent?.trim()).to.equal("Parent 1"); + + const leafRows = el.shadowRoot?.querySelectorAll(".tree-node-leaf") ?? []; + const leafWithCheckbox = Array.from(leafRows).find( + row => row.querySelector("bl-checkbox") + ); + + expect(leafWithCheckbox).to.exist; + }); + + it("single mode: selecting leaf dispatches change and closes", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const leafInput = getCheckboxInput(el, ".tree-node-leaf .tree-checkbox"); + + const promise = oneEvent(el, "bl-tree-select-change"); + + setTimeout(() => leafInput?.click()); + const ev = await promise; + + expect(ev.detail?.value).to.equal("p2"); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.not.exist; + }); + + it("_setSingleLeafValue returns early when disabled or isMultiple", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const setSingleLeafValue = (el as unknown as { + _setSingleLeafValue(node: TreeNode, checked: boolean): void; + })._setSingleLeafValue.bind(el); + const leafNode = sampleTree[1]; + + setSingleLeafValue(leafNode, true); + expect(el.value).to.equal("p2"); + (el as unknown as { disabled: boolean }).disabled = true; + await elementUpdated(el); + setSingleLeafValue(sampleTree[0]?.children?.[0] as TreeNode, true); + expect(el.value).to.equal("p2"); + + const elMulti = await fixture( + html`` + ); + + await elementUpdated(elMulti); + const setSingleLeafValueMulti = (elMulti as unknown as { + _setSingleLeafValue(node: TreeNode, checked: boolean): void; + })._setSingleLeafValue.bind(elMulti); + + setSingleLeafValueMulti(sampleTree[1] as TreeNode, true); + expect(elMulti.value).to.deep.equal(["c1"]); + }); + + it("_setSingleLeafValue sets value to null when checked is false", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const setSingleLeafValue = (el as unknown as { + _setSingleLeafValue(node: TreeNode, checked: boolean): void; + })._setSingleLeafValue.bind(el); + const promise = oneEvent(el, "bl-tree-select-change"); + + setSingleLeafValue(sampleTree[1] as TreeNode, false); + await promise; + expect(el.value).to.be.null; + }); + + it("single mode: _applySelection sets value to first item or null (else branch)", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const applySelection = (el as unknown as { _applySelection(set: Set): void }) + ._applySelection.bind(el); + + const promise1 = oneEvent(el, "bl-tree-select-change"); + + applySelection(new Set(["p2"])); + const ev1 = await promise1; + + expect(ev1.detail?.value).to.equal("p2"); + expect(el.value).to.equal("p2"); + + const promise2 = oneEvent(el, "bl-tree-select-change"); + + applySelection(new Set()); + const ev2 = await promise2; + + expect(ev2.detail?.value).to.be.null; + expect(el.value).to.be.null; + }); + + it("_highlightPath returns display path without highlight when search is empty (!searchLower)", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const highlightPath = (el as unknown as { + _highlightPath(path: string, search: string): TemplateResult; + })._highlightPath.bind(el); + const result = highlightPath("Parent 1 / Child 1", ""); + const container = document.createElement("div"); + + render(result, container); + expect(container.textContent?.trim()).to.equal("Parent 1/Child 1"); + expect(container.querySelector(".autocomplete-match")).to.be.null; + }); + + it("_focusedValue returns null when _focusedIndex is out of bounds", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const host = el as unknown as { _focusedIndex: number; _focusedValue: string | "select-all" | null }; + + host._focusedIndex = -1; + expect(host._focusedValue).to.be.null; + + host._focusedIndex = 999; + expect(host._focusedValue).to.be.null; + }); + + it("_scrollFocusedIntoView returns early when _focusedValue is null", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + (el as unknown as { _focusedIndex: number })._focusedIndex = -1; + const scrollFocusedIntoView = (el as unknown as { _scrollFocusedIntoView(): void }) + ._scrollFocusedIntoView.bind(el); + + scrollFocusedIntoView(); + await new Promise(r => requestAnimationFrame(() => r())); + await elementUpdated(el); + }); + + it("_selectAutocompleteItem with leaf node sets value and closes in single mode", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const input = getSearchInput(el); + + input!.value = "child"; + input?.dispatchEvent(new InputEvent("input", { bubbles: true })); + await elementUpdated(el); + const leafRow = el.shadowRoot?.querySelector(".autocomplete-row"); + + expect(leafRow).to.exist; + const promise = oneEvent(el, "bl-tree-select-change"); + + (leafRow as HTMLElement).click(); + await promise; + await elementUpdated(el); + expect(el.value).to.equal("c1"); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.not.exist; + }); + + it("_selectAutocompleteItem with node that has children in single mode does not set value", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const parentNode = sampleTree[0]; + const selectAutocompleteItem = (el as unknown as { + _selectAutocompleteItem(node: TreeNode): void; + })._selectAutocompleteItem.bind(el); + + selectAutocompleteItem(parentNode); + await elementUpdated(el); + expect(el.value).to.be.null; + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.exist; + }); + + it("_isIndeterminate returns false when childValues.length === 0", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const nodeWithChildren = sampleTree[0]; + const origAllValues = (el as unknown as { _allValues(nodes: TreeNode[]): Set }) + ._allValues.bind(el); + + (el as unknown as { _allValues(nodes: TreeNode[]): Set })._allValues = () => + new Set(); + const isIndeterminate = (el as unknown as { _isIndeterminate(node: TreeNode): boolean }) + ._isIndeterminate.bind(el); + + expect(isIndeterminate(nodeWithChildren)).to.be.false; + (el as unknown as { _allValues(nodes: TreeNode[]): Set })._allValues = origAllValues; + }); + + it("_getDisplayText returns empty string when single value not found in tree", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const getDisplayText = (el as unknown as { _getDisplayText(): string })._getDisplayText.bind(el); + + expect(getDisplayText()).to.equal(""); + }); + + it("_findNodeByValue returns found node when value is in children", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const findNodeByValue = (el as unknown as { + _findNodeByValue(value: string, nodes?: TreeNode[]): TreeNode | null; + })._findNodeByValue.bind(el); + + const c1 = findNodeByValue("c1"); + + expect(c1).to.not.be.null; + expect(c1?.value).to.equal("c1"); + expect(c1?.label).to.equal("Child 1"); + const c2 = findNodeByValue("c2"); + + expect(c2).to.not.be.null; + expect(c2?.value).to.equal("c2"); + }); + + it("_findNodeByValue returns null when value does not exist in tree", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const findNodeByValue = (el as unknown as { + _findNodeByValue(value: string, nodes?: TreeNode[]): TreeNode | null; + })._findNodeByValue.bind(el); + + expect(findNodeByValue("nonexistent")).to.be.null; + }); + + it("_findNodeByValue returns deeply nested node via recursive found branch", async () => { + const deepTree: TreeNode[] = [ + { + value: "root", + label: "Root", + children: [ + { + value: "mid", + label: "Mid", + children: [ + { value: "deep-leaf", label: "Deep Leaf" }, + ], + }, + ], + }, + ]; + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const findNodeByValue = (el as unknown as { + _findNodeByValue(value: string, nodes?: TreeNode[]): TreeNode | null; + })._findNodeByValue.bind(el); + + const found = findNodeByValue("deep-leaf"); + + expect(found).to.not.be.null; + expect(found?.value).to.equal("deep-leaf"); + expect(found?.label).to.equal("Deep Leaf"); + }); + + it("expand/collapse toggles children visibility", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const expandBtn = el.shadowRoot?.querySelector(".tree-expand[aria-expanded]") as HTMLElement; + + expect(expandBtn?.getAttribute("aria-expanded")).to.equal("false"); + expandBtn?.click(); + await elementUpdated(el); + expect(expandBtn?.getAttribute("aria-expanded")).to.equal("true"); + expect(el.shadowRoot?.querySelector(".tree-node-expanded")).to.exist; + expect(el.shadowRoot?.querySelector(".tree-children")).to.exist; + + expandBtn?.click(); + await elementUpdated(el); + expect(expandBtn?.getAttribute("aria-expanded")).to.equal("false"); + }); + + it("node without count renders no tree-node-count", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const expandBtn = el.shadowRoot?.querySelector(".tree-expand[aria-expanded]") as HTMLElement; + + expandBtn?.click(); + await elementUpdated(el); + const c2Row = el.shadowRoot?.querySelector('[data-tree-focus="c2"]')?.closest(".tree-node"); + + expect(c2Row).to.exist; + expect(c2Row?.querySelector(".tree-node-count")).to.not.exist; + }); + + it("single mode: parent without count renders no tree-node-count", async () => { + const treeParentNoCount: TreeNode[] = [ + { + value: "p", + label: "Parent", + children: [{ value: "c", label: "Child" }], + }, + ]; + + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const parentRow = el.shadowRoot?.querySelector(".tree-node-single-parent"); + + expect(parentRow).to.exist; + expect(parentRow?.querySelector(".tree-node-count")).to.not.exist; + }); + + it("single mode: leaf without count renders no tree-node-count", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const expandBtn = el.shadowRoot?.querySelector(".tree-expand[aria-expanded]") as HTMLElement; + + expandBtn?.click(); + await elementUpdated(el); + const c2Row = el.shadowRoot?.querySelector('[data-tree-focus="c2"]')?.closest(".tree-node"); + + expect(c2Row).to.exist; + expect(c2Row?.querySelector(".tree-node-count")).to.not.exist; + }); + + it("typing in input filters to autocomplete list", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const input = getSearchInput(el); + + input?.focus(); + input!.value = "child"; + input?.dispatchEvent(new InputEvent("input", { bubbles: true })); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".autocomplete-list")).to.exist; + expect(el.shadowRoot?.querySelectorAll(".autocomplete-row").length).to.be.greaterThan(0); + }); + + it("shows empty state when search has no results", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const input = getSearchInput(el); + + input!.value = "xyznonexistent"; + input?.dispatchEvent(new InputEvent("input", { bubbles: true })); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".autocomplete-empty")).to.exist; + expect(el.shadowRoot?.querySelector(".autocomplete-empty")?.textContent?.trim()).to.equal( + "No results" + ); + }); + + it("shows loading spinner when isSearchLoading and open", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-loading")).to.exist; + }); + + it("chevron click toggles open state", async () => { + const el = await fixture( + html`` + ); + + const trigger = getTrigger(el); + + trigger?.click(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.exist; + + trigger?.click(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.not.exist; + }); + + it("trigger Enter opens and Escape closes", async () => { + const el = await fixture( + html`` + ); + + const trigger = getTrigger(el); + + trigger?.focus(); + await sendKeys({ press: "Enter" }); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.exist; + + await sendKeys({ press: "Escape" }); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.not.exist; + }); + + it("ArrowDown increases focused index when panel has items", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const trigger = getTrigger(el); + + trigger?.focus(); + await sendKeys({ press: "ArrowDown" }); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-node-row-focused")).to.exist; + }); + + it("has-value class when single value is set", async () => { + const el = await fixture( + html`` + ); + + expect(el.shadowRoot?.querySelector(".tree-select-has-value")).to.exist; + }); + + it("has-value class when multiple values are set", async () => { + const el = await fixture( + html`` + ); + + expect(el.shadowRoot?.querySelector(".tree-select-has-value")).to.exist; + }); + + it("panel has role listbox when isMultiple and role tree when single", async () => { + const multi = await fixture( + html`` + ); + + multi.open(); + await elementUpdated(multi); + expect(multi.shadowRoot?.querySelector(".tree-select-panel")?.getAttribute("role")).to.equal( + "listbox" + ); + + const single = await fixture( + html`` + ); + + single.open(); + await elementUpdated(single); + expect(single.shadowRoot?.querySelector(".tree-select-panel")?.getAttribute("role")).to.equal( + "tree" + ); + }); + + it("clicking autocomplete row selects item and dispatches change in multiple mode", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const input = getSearchInput(el); + + input!.value = "parent"; + input?.dispatchEvent(new InputEvent("input", { bubbles: true })); + await elementUpdated(el); + const firstRow = el.shadowRoot?.querySelector(".autocomplete-row") as HTMLElement; + + const promise = oneEvent(el, "bl-tree-select-change"); + + firstRow?.click(); + await promise; + expect(el.selectedSet.size).to.be.greaterThan(0); + }); + + it("single mode: clicking autocomplete leaf row selects value and closes panel", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const input = getSearchInput(el); + + input!.value = "child"; + input?.dispatchEvent(new InputEvent("input", { bubbles: true })); + await elementUpdated(el); + const rows = el.shadowRoot?.querySelectorAll(".autocomplete-row") ?? []; + const leafRow = Array.from(rows).find( + row => (row.getAttribute("data-tree-focus") === "c1" || row.getAttribute("data-tree-focus") === "c2") + ) as HTMLElement; + + const promise = oneEvent(el, "bl-tree-select-change"); + + leafRow?.click(); + const ev = await promise; + + expect(["c1", "c2"]).to.include(ev.detail?.value); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.not.exist; + }); + + it("parent node toggle selects all descendants in multiple mode", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const expandBtn = el.shadowRoot?.querySelector(".tree-expand[aria-expanded]") as HTMLElement; + + expandBtn?.click(); + await elementUpdated(el); + const parentInput = getCheckboxInput(el, ".tree-node .tree-checkbox"); + + const promise = oneEvent(el, "bl-tree-select-change"); + + setTimeout(() => parentInput?.click()); + await promise; + await elementUpdated(el); + expect(el.selectedSet.has("p1")).to.be.true; + expect(el.selectedSet.has("c1")).to.be.true; + expect(el.selectedSet.has("c2")).to.be.true; + }); + + it("selectedSet is empty when value is null", async () => { + const el = await fixture( + html`` + ); + + expect(el.selectedSet.size).to.equal(0); + }); + + it("close resets search text", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const input = getSearchInput(el); + + input!.value = "test"; + input?.dispatchEvent(new InputEvent("input", { bubbles: true })); + await elementUpdated(el); + el.close(); + await elementUpdated(el); + + const host = el as unknown as { _searchText: string }; + + expect(host._searchText).to.equal(""); + }); + + it("handles value as array for single mode (_singleValue)", async () => { + const el = await fixture( + html`` + ); + + expect(el.selectedSet.has("c1")).to.be.true; + expect(el.shadowRoot?.querySelector(".tree-select-has-value")).to.exist; + }); + + it("ArrowUp moves focus when panel open", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const trigger = getTrigger(el); + + trigger?.focus(); + await sendKeys({ press: "ArrowDown" }); + await sendKeys({ press: "ArrowDown" }); + await elementUpdated(el); + await sendKeys({ press: "ArrowUp" }); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-node-row-focused")).to.exist; + }); + + it("Space on trigger opens", async () => { + const el = await fixture( + html`` + ); + + const trigger = getTrigger(el); + + trigger?.focus(); + await sendKeys({ press: " " }); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.exist; + }); + + it("trigger is present and disabled attribute propagates", async () => { + const enabled = await fixture( + html`` + ); + + const triggerEnabled = getTrigger(enabled); + + expect(triggerEnabled).to.exist; + expect(triggerEnabled?.getAttribute("tabindex")).to.equal("0"); + + const disabled = await fixture( + html`` + ); + + const triggerDisabled = getTrigger(disabled); + + expect(triggerDisabled?.getAttribute("tabindex")).to.equal("-1"); + }); + + it("empty-result-text attribute is reflected", async () => { + const el = await fixture( + html`` + ); + + expect(el.searchNotFoundText).to.equal("No items"); + }); + + it("ArrowRight expands focused node when panel open", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const trigger = getTrigger(el); + + trigger?.focus(); + await sendKeys({ press: "ArrowRight" }); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-node-expanded")).to.exist; + }); + + it("ArrowLeft collapses focused node when expanded", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const trigger = getTrigger(el); + + trigger?.focus(); + await sendKeys({ press: "ArrowRight" }); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-node-expanded")).to.exist; + + await sendKeys({ press: "ArrowLeft" }); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-node-expanded")).to.not.exist; + }); + + it("_onPanelKeydown default branch does nothing for unhandled key", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const host = el as unknown as { + _onPanelKeydown(e: KeyboardEvent): void; + _focusedIndex: number; + }; + const prevIndex = host._focusedIndex; + const prevValue = el.value; + + host._onPanelKeydown(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + await elementUpdated(el); + expect(host._focusedIndex).to.equal(prevIndex); + expect(el.value).to.equal(prevValue); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.exist; + }); + + it("Enter on focused option triggers selection", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const host = el as unknown as { + _onPanelKeydown(e: KeyboardEvent): void; + _focusedIndex: number; + }; + + host._focusedIndex = 0; + const promise = oneEvent(el, "bl-tree-select-change"); + + host._onPanelKeydown(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await promise; + expect(el.selectedSet.size).to.be.greaterThan(0); + }); + + it("Space on focused option triggers selection (case Space)", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const host = el as unknown as { + _onPanelKeydown(e: KeyboardEvent): void; + _focusedIndex: number; + }; + + host._focusedIndex = 0; + const promise = oneEvent(el, "bl-tree-select-change"); + + host._onPanelKeydown(new KeyboardEvent("keydown", { key: " ", bubbles: true })); + await promise; + expect(el.selectedSet.size).to.be.greaterThan(0); + }); + + it("Enter when Select All is focused calls handleSelectAll", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const host = el as unknown as { + _onPanelKeydown(e: KeyboardEvent): void; + _focusedIndex: number; + }; + + host._focusedIndex = 0; + const promise1 = oneEvent(el, "bl-tree-select-change"); + + host._onPanelKeydown(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await promise1; + await elementUpdated(el); + expect(el.selectedSet.size).to.be.greaterThan(0); + + const promise2 = oneEvent(el, "bl-tree-select-change"); + + host._onPanelKeydown(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await promise2; + await elementUpdated(el); + expect(el.selectedSet.size).to.equal(0); + }); + + it("Space when Select All is focused toggles select all", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const host = el as unknown as { + _onPanelKeydown(e: KeyboardEvent): void; + _focusedIndex: number; + }; + + host._focusedIndex = 0; + const promise1 = oneEvent(el, "bl-tree-select-change"); + + host._onPanelKeydown(new KeyboardEvent("keydown", { key: " ", bubbles: true })); + await promise1; + await elementUpdated(el); + expect(el.selectedSet.size).to.be.greaterThan(0); + + const promise2 = oneEvent(el, "bl-tree-select-change"); + + host._onPanelKeydown(new KeyboardEvent("keydown", { key: " ", bubbles: true })); + await promise2; + await elementUpdated(el); + expect(el.selectedSet.size).to.equal(0); + }); + + it("_filterVisible returns parent with filteredChildren when only child matches (childMatch branch)", async () => { + const deepTree: TreeNode[] = [ + { + value: "fruits", + label: "Fruits", + children: [ + { value: "apple", label: "Apple" }, + { value: "grape", label: "Grape" }, + ], + }, + { value: "vegetables", label: "Vegetables" }, + ]; + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const filterVisible = (el as unknown as { + _filterVisible(nodes: TreeNode[], searchLower: string): TreeNode[]; + })._filterVisible.bind(el); + + const result = filterVisible(deepTree, "apple"); + + expect(result.length).to.equal(1); + expect(result[0].value).to.equal("fruits"); + expect(result[0].children?.length).to.equal(1); + expect(result[0].children?.[0].value).to.equal("apple"); + }); + + it("_filterVisible returns node with original children when selfMatch true but no filteredChildren (node.children fallback)", async () => { + const treeWithEmptyChildren: TreeNode[] = [ + { value: "p2", label: "Parent 2", count: 0, children: [] }, + ]; + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const filterVisible = (el as unknown as { + _filterVisible(nodes: TreeNode[], searchLower: string): TreeNode[]; + })._filterVisible.bind(el); + + const result = filterVisible(treeWithEmptyChildren, "parent 2"); + + expect(result.length).to.equal(1); + expect(result[0].value).to.equal("p2"); + expect(result[0].children).to.deep.equal([]); + }); + + it("_filterVisible returns null for nodes where neither selfMatch nor childMatch (null branch)", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const filterVisible = (el as unknown as { + _filterVisible(nodes: TreeNode[], searchLower: string): TreeNode[]; + })._filterVisible.bind(el); + + const result = filterVisible(sampleTree, "xyznotfound"); + + expect(result.length).to.equal(0); + }); + + it("_selectAllState returns checked:false, indeterminate:false when items are empty", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const host = el as unknown as { + _selectAllState: { checked: boolean; indeterminate: boolean }; + }; + + expect(host._selectAllState.checked).to.be.false; + expect(host._selectAllState.indeterminate).to.be.false; + }); + + it("_onPanelKeydown returns early when focusable list is empty", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const host = el as unknown as { + _onPanelKeydown(e: KeyboardEvent): void; + _focusedIndex: number; + }; + + host._focusedIndex = 0; + const event = new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }); + + host._onPanelKeydown(event); + await elementUpdated(el); + expect(host._focusedIndex).to.equal(0); + expect(el.value).to.be.null; + }); + + it("disabled trigger prevents opening", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + el.open(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.not.exist; + }); + + it("_handleTriggerClick returns early when disabled", async () => { + const el = await fixture( + html`` + ); + + const trigger = getTrigger(el); + + trigger?.click(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.not.exist; + }); + + it("_handleTriggerKeydown returns early when disabled", async () => { + const el = await fixture( + html`` + ); + + const host = el as unknown as { + _handleTriggerKeydown(e: KeyboardEvent): void; + _open: boolean; + }; + + host._handleTriggerKeydown(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await elementUpdated(el); + expect(host._open).to.be.false; + }); + + it("_handleTriggerKeydown delegates to _onPanelKeydown when open and key is not Enter/Space/Escape", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const host = el as unknown as { + _handleTriggerKeydown(e: KeyboardEvent): void; + _focusedIndex: number; + }; + + host._focusedIndex = 0; + host._handleTriggerKeydown(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + await elementUpdated(el); + expect(host._focusedIndex).to.equal(1); + }); + + it("_onPopoverHide resets state when panel was open", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const input = getSearchInput(el); + + input!.value = "child"; + input?.dispatchEvent(new InputEvent("input", { bubbles: true })); + await elementUpdated(el); + + const host = el as unknown as { + _onPopoverHide(): void; + _open: boolean; + _searchText: string; + _focusedIndex: number; + }; + + host._onPopoverHide(); + await elementUpdated(el); + expect(host._open).to.be.false; + expect(host._searchText).to.equal(""); + expect(host._focusedIndex).to.equal(0); + }); + + it("_onPopoverHide does nothing when panel was already closed", async () => { + const el = await fixture( + html`` + ); + + const host = el as unknown as { + _onPopoverHide(): void; + _open: boolean; + }; + + expect(host._open).to.be.false; + host._onPopoverHide(); + expect(host._open).to.be.false; + }); + + it("_getDisplayText returns comma-separated labels in multiple mode", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const getDisplayText = (el as unknown as { _getDisplayText(): string })._getDisplayText.bind(el); + const result = getDisplayText(); + + expect(result).to.include("Child 1"); + expect(result).to.include("Child 2"); + expect(result).to.include(", "); + }); + + it("_getDisplayText returns label for single selected value", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const getDisplayText = (el as unknown as { _getDisplayText(): string })._getDisplayText.bind(el); + + expect(getDisplayText()).to.equal("Child 1"); + }); + + it("_getDisplayText returns empty string when no selection in multiple mode", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const getDisplayText = (el as unknown as { _getDisplayText(): string })._getDisplayText.bind(el); + + expect(getDisplayText()).to.equal(""); + }); + + it("input click does not close panel when open", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const input = getSearchInput(el); + + input?.click(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-open")).to.exist; + }); + + it("firstUpdated sets popover target to trigger element", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const popover = el.shadowRoot?.querySelector("bl-popover") as HTMLElement & { target: Element }; + const trigger = getTrigger(el); + + expect(popover).to.exist; + expect(popover.target).to.equal(trigger); + }); + + it("placeholder shows display text when closed and value is set", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + const input = getSearchInput(el); + + expect(input?.placeholder).to.equal("Child 1"); + }); + + it("placeholder shows searchPlaceholder when open", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const input = getSearchInput(el); + + expect(input?.placeholder).to.equal("Search..."); + }); + + it("placeholder falls back to localized default when open and no searchPlaceholder or placeholder", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const input = getSearchInput(el); + + expect(input?.placeholder).to.equal("Search..."); + }); + + it("updated resets _focusedIndex when _open changes", async () => { + const el = await fixture( + html`` + ); + + el.open(); + await elementUpdated(el); + const host = el as unknown as { _focusedIndex: number }; + + host._focusedIndex = 5; + el.open(); + await elementUpdated(el); + expect(host._focusedIndex).to.be.lessThanOrEqual(1); + }); + + it("chevron rotates when open", async () => { + const el = await fixture( + html`` + ); + + expect(el.shadowRoot?.querySelector(".tree-select-chevron-open")).to.not.exist; + el.open(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-chevron-open")).to.exist; + }); + + it("loading spinner is hidden when not searching or closed", async () => { + const el = await fixture( + html`` + ); + + expect(el.shadowRoot?.querySelector(".tree-select-loading")).to.not.exist; + }); + + it("trigger has tree-select-trigger-has-value class when value set and closed", async () => { + const el = await fixture( + html`` + ); + + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-trigger-has-value")).to.exist; + + el.open(); + await elementUpdated(el); + expect(el.shadowRoot?.querySelector(".tree-select-trigger-has-value")).to.not.exist; + }); + + it("label is not rendered when empty", async () => { + const el = await fixture( + html`` + ); + + expect(el.shadowRoot?.querySelector(".header")).to.not.exist; + }); +}); diff --git a/src/components/tree-select/bl-tree-select.ts b/src/components/tree-select/bl-tree-select.ts new file mode 100644 index 000000000..12a961217 --- /dev/null +++ b/src/components/tree-select/bl-tree-select.ts @@ -0,0 +1,789 @@ +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { localized, msg, str } from "@lit/localize"; +import { event, EventDispatcher } from "../../utilities/event"; +import "../button/bl-button"; +import "../checkbox-group/checkbox/bl-checkbox"; +import "../icon/bl-icon"; +import "../popover/bl-popover"; +import type BlPopover from "../popover/bl-popover"; +import "../spinner/bl-spinner"; +import style from "./bl-tree-select.css"; + +export interface TreeNode { + value: string; + label: string; + count?: number; + children?: TreeNode[]; +} + +/** + * @tag bl-tree-select + * @summary Baklava Tree Select component for hierarchical selection (e.g. category tree). + * Supports single/multi select, expand/collapse, Select All, search with path display. + */ +@customElement("bl-tree-select") +@localized() +export default class BlTreeSelect extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + /** + * Label above the input (e.g. "Kategori*") + */ + @property({ type: String, attribute: "label", reflect: true }) + label = ""; + + /** + * Placeholder for the input (e.g. "Kategori girin") + */ + @property({ type: String, reflect: true }) + placeholder = ""; + + /** + * When searching text loading icon + */ + @property({ type: Boolean, reflect: false }) + isSearchLoading = false; + + /** + * Tree data: array of root nodes. Each node has value, label, optional count, optional children. + */ + @property({ type: Array }) + items: TreeNode[] = []; + + /** + * Selected value(s). Single: string. Multiple: string[]. + */ + @property({ type: Array }) + value: string | string[] | null = null; + + /** + * When true, multiple selection with checkboxes and Select All. When false, single selection: only leaf nodes have checkboxes. + * Attribute: is-multiple + */ + @property({ type: Boolean, attribute: "is-multiple", reflect: true }) + isMultiple = true; + + /** + * Show "Select All" header in the dropdown (only when multiple) + */ + @property({ type: Boolean, attribute: "view-select-all", reflect: true }) + viewSelectAll = false; + + /** + * Text for Select All (e.g. "Select All") + */ + @property({ type: String, attribute: "select-all-text", reflect: true }) + selectAllText = ""; + + /** + * Placeholder for search input inside dropdown + */ + @property({ type: String, attribute: "search-placeholder", reflect: true }) + searchPlaceholder = ""; + + /** + * Disabled state + */ + @property({ type: Boolean, reflect: true }) + disabled = false; + + /** + * Required state + */ + @property({ type: Boolean, reflect: true }) + required = false; + + @property({ type: String, attribute: "empty-result-text", reflect: true }) + searchNotFoundText?: string; + + @state() + private _open = false; + + @state() + private _searchText = ""; + + @state() + private _expandedValues = new Set(); + + @state() + private _focusedIndex = 0; + + @query("bl-popover") + private _popover!: BlPopover; + + @query(".tree-select-input") + private _inputEl!: HTMLInputElement; + + @event("bl-tree-select-change") + private _onChange: EventDispatcher<{ value: string | string[] | null }>; + + get selectedSet(): Set { + if (this.value == null) return new Set(); + const arr = Array.isArray(this.value) ? this.value : [this.value]; + + return new Set(arr); + } + + private get _hasValue(): boolean { + return ( + (this.isMultiple && this.selectedSet.size > 0) || + (!this.isMultiple && this._singleValue != null && this._singleValue !== "") + ); + } + + private get _singleValue(): string | null { + if (this.value == null) return null; + if (Array.isArray(this.value)) return this.value[0] ?? null; + return this.value; + } + + private get _autocompleteList(): { node: TreeNode; path: string }[] { + const q = this._searchText.trim().toLowerCase(); + + if (!q) return []; + const flat = this._flatWithPath(this.items); + + return flat.filter(({ path }) => path.toLowerCase().includes(q)); + } + + private _highlightPath(path: string, search: string): TemplateResult { + const displayPath = path.replace(/\s*\/\s*/g, "/"); + const searchTrimmed = search.trim(); + const searchLower = searchTrimmed.toLowerCase(); + + if (!searchLower) return html`${displayPath}`; + const pathLower = displayPath.toLowerCase(); + const segments: { text: string; match: boolean }[] = []; + let pos = 0; + + while (pos < displayPath.length) { + const idx = pathLower.indexOf(searchLower, pos); + + if (idx === -1) { + if (pos < displayPath.length) segments.push({ text: displayPath.slice(pos), match: false }); + break; + } + if (idx > pos) segments.push({ text: displayPath.slice(pos, idx), match: false }); + segments.push({ + text: displayPath.slice(idx, idx + searchTrimmed.length), + match: true, + }); + pos = idx + searchTrimmed.length; + } + return html` + ${segments.map(s => + s.match ? html`${s.text}` : s.text + )} + `; + } + + private get _focusableValues(): (string | "select-all")[] { + const list = this._autocompleteList; + + if (list.length > 0) return list.map(({ node }) => node.value); + const visible = this._filterVisible(this.items, this._searchText.toLowerCase()); + const flat = this._flattenVisible(visible); + const values = flat.map(n => n.value); + + if (this.isMultiple && this.viewSelectAll) return ["select-all", ...values]; + return values; + } + + private get _focusedValue(): string | "select-all" | null { + const list = this._focusableValues; + + if (this._focusedIndex < 0 || this._focusedIndex >= list.length) return null; + return list[this._focusedIndex]; + } + + private _selectAutocompleteItem(node: TreeNode) { + if (this.isMultiple) { + this._toggleNode(node, !this._isChecked(node)); + } else { + if (!node.children?.length) { + this._setSingleLeafValue(node, true); + this.close(); + } + } + } + + private _flatWithPath(nodes: TreeNode[], pathPrefix = ""): { node: TreeNode; path: string }[] { + const result: { node: TreeNode; path: string }[] = []; + + for (const node of nodes) { + const path = pathPrefix ? `${pathPrefix} / ${node.label}` : node.label; + + result.push({ node, path }); + if (node.children?.length) { + result.push(...this._flatWithPath(node.children, path)); + } + } + return result; + } + + private _allValues(nodes: TreeNode[]): Set { + const set = new Set(); + + for (const node of nodes) { + set.add(node.value); + if (node.children?.length) { + this._allValues(node.children).forEach(v => set.add(v)); + } + } + return set; + } + + private _flattenVisible(nodes: TreeNode[]): TreeNode[] { + const result: TreeNode[] = []; + + const visit = (list: TreeNode[]) => { + for (const node of list) { + result.push(node); + if (node.children?.length && this._expandedValues.has(node.value)) { + visit(node.children); + } + } + }; + + visit(nodes); + return result; + } + + private _filterVisible(nodes: TreeNode[], searchLower: string): TreeNode[] { + if (!searchLower) return nodes; + const flat = this._flatWithPath(nodes); + const matchingValues = new Set( + flat + .filter(({ path }) => path.toLowerCase().includes(searchLower)) + .map(({ node }) => node.value) + ); + const filterNode = (node: TreeNode): TreeNode | null => { + const selfMatch = matchingValues.has(node.value); + const filteredChildren = node.children?.length + ? node.children.map(filterNode).filter((n): n is TreeNode => n != null) + : undefined; + const childMatch = filteredChildren && filteredChildren.length > 0; + + if (selfMatch || childMatch) { + return { + ...node, + children: filteredChildren?.length ? filteredChildren : node.children, + }; + } + return null; + }; + + return nodes.map(filterNode).filter((n): n is TreeNode => n != null); + } + + private _isChecked(node: TreeNode): boolean { + const sel = this.selectedSet; + + if (!sel.has(node.value)) return false; + if (!node.children?.length) return true; + const childValues = this._allValues(node.children); + + return [...childValues].every(v => sel.has(v)); + } + + private _isIndeterminate(node: TreeNode): boolean { + const sel = this.selectedSet; + + if (!node.children?.length) return false; + const childValues = [...this._allValues(node.children)]; + + if (childValues.length === 0) return false; + const selectedCount = childValues.filter(v => sel.has(v)).length; + + return selectedCount > 0 && selectedCount < childValues.length; + } + + private _isExpanded(node: TreeNode): boolean { + return this._expandedValues.has(node.value); + } + + private _toggleExpand(node: TreeNode) { + const next = new Set(this._expandedValues); + + if (next.has(node.value)) next.delete(node.value); + else next.add(node.value); + this._expandedValues = next; + } + + private _findNodeByValue(value: string, nodes: TreeNode[] = this.items): TreeNode | null { + for (const node of nodes) { + if (node.value === value) return node; + if (node.children?.length) { + const found = this._findNodeByValue(value, node.children); + + if (found) return found; + } + } + return null; + } + + private _scrollFocusedIntoView() { + this.requestUpdate(); + requestAnimationFrame(() => { + const value = this._focusedValue; + + if (value == null) return; + const el = this.renderRoot.querySelector(`[data-tree-focus="${value}"]`); + + el?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }); + } + + private _onPanelKeydown(e: KeyboardEvent) { + const list = this._focusableValues; + + if (list.length === 0) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + this._focusedIndex = Math.min(this._focusedIndex + 1, list.length - 1); + this._scrollFocusedIntoView(); + break; + case "ArrowUp": + e.preventDefault(); + this._focusedIndex = Math.max(0, this._focusedIndex - 1); + this._scrollFocusedIntoView(); + break; + case "ArrowLeft": + e.preventDefault(); + if (this._focusedValue && this._focusedValue !== "select-all") { + const node = this._findNodeByValue(this._focusedValue); + + if (node && this._isExpanded(node)) this._toggleExpand(node); + } + break; + case "ArrowRight": + e.preventDefault(); + if (this._focusedValue && this._focusedValue !== "select-all") { + const node = this._findNodeByValue(this._focusedValue); + + if (node?.children?.length && !this._isExpanded(node)) this._toggleExpand(node); + } + break; + case " ": + case "Enter": + e.preventDefault(); + if (this._focusedValue === "select-all") { + this._handleSelectAll(!this._selectAllState.checked); + } else if (this._focusedValue) { + const node = this._findNodeByValue(this._focusedValue); + + if (node) this._selectAutocompleteItem(node); + } + break; + default: + break; + } + } + + private _setSingleLeafValue(node: TreeNode, checked: boolean) { + if (this.disabled || this.isMultiple) return; + this.value = checked ? node.value : null; + this._onChange({ value: this.value }); + if (checked) this.close(); + } + + private _toggleNode(node: TreeNode, checked: boolean) { + const values = this._allValues([node]); + const newSet = new Set(this.selectedSet); + + values.forEach(v => (checked ? newSet.add(v) : newSet.delete(v))); + this._applySelection(newSet); + } + + private _applySelection(set: Set) { + if (this.isMultiple) { + this.value = Array.from(set); + } else { + this.value = set.size ? Array.from(set)[0] : null; + } + this._onChange({ value: this.value }); + this.requestUpdate(); + } + + private _handleSelectAll(checked: boolean) { + const visible = this._filterVisible(this.items, this._searchText.toLowerCase()); + const allValues = this._allValues(visible); + const newSet = new Set(this.selectedSet); + + if (checked) { + allValues.forEach(v => newSet.add(v)); + } else { + allValues.forEach(v => newSet.delete(v)); + } + this._applySelection(newSet); + } + + private get _selectAllState(): { checked: boolean; indeterminate: boolean } { + const visible = this._filterVisible(this.items, this._searchText.toLowerCase()); + const allValues = this._allValues(visible); + + if (allValues.size === 0) return { checked: false, indeterminate: false }; + const selectedCount = [...allValues].filter(v => this.selectedSet.has(v)).length; + + return { + checked: selectedCount === allValues.size, + indeterminate: selectedCount > 0 && selectedCount < allValues.size, + }; + } + + private _getDisplayText(): string { + if (!this.isMultiple) { + const single = this._singleValue; + + if (single == null || single === "") return ""; + const flat = this._flatWithPath(this.items); + const found = flat.find(({ node }) => node.value === single); + + return found ? found.node.label : ""; + } + const sel = this.selectedSet; + + if (sel.size === 0) return ""; + const flat = this._flatWithPath(this.items); + const labels: string[] = []; + + for (const v of sel) { + const found = flat.find(({ node }) => node.value === v); + + if (found) labels.push(found.node.label); + } + return labels.join(", "); + } + + open() { + if (this.disabled) return; + this._open = true; + this._popover?.show(); + this.updateComplete.then(() => { + this._inputEl?.focus(); + }); + } + + close() { + this._open = false; + this._searchText = ""; + this._focusedIndex = 0; + this._popover?.hide(); + } + + private _clearSelection() { + this.value = this.isMultiple ? [] : null; + this._onChange({ value: this.value }); + } + + protected firstUpdated() { + this._popover.target = this.renderRoot.querySelector(".tree-select-trigger") as Element; + } + + private _handleInputChange(e: Event) { + this._searchText = (e.target as HTMLInputElement).value; + } + + private _handleTriggerClick() { + if (this.disabled) return; + if (this._open) { + this.close(); + } else { + this.open(); + } + } + + private _handleTriggerKeydown(e: KeyboardEvent) { + if (this.disabled) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (!this._open) { + this.open(); + } + } else if (e.key === "Escape" && this._open) { + e.preventDefault(); + this.close(); + } else if (this._open) { + this._onPanelKeydown(e); + } + } + + private _onPopoverHide() { + if (this._open) { + this._open = false; + this._searchText = ""; + this._focusedIndex = 0; + } + } + + protected updated(_changedProperties: Map) { + super.updated(_changedProperties); + if (this._open) { + if (_changedProperties.has("_open") || _changedProperties.has("_searchText")) + this._focusedIndex = 0; + const list = this._focusableValues; + + this._focusedIndex = Math.min(this._focusedIndex, Math.max(0, list.length - 1)); + } + } + + private _renderCount(node: TreeNode): TemplateResult | string { + return node.count != null ? html`${node.count}` : ""; + } + + private _renderNode(node: TreeNode, depth: number): TemplateResult { + const hasChildren = !!node.children?.length; + const expanded = this._isExpanded(node); + const checked = this._isChecked(node); + const indeterminate = this._isIndeterminate(node); + const expandIcon = hasChildren ? (expanded ? "arrow_down" : "arrow_right") : null; + const singleMode = !this.isMultiple; + const isLeaf = !hasChildren; + const singleLeafChecked = singleMode && isLeaf && this._singleValue === node.value; + const singleParent = singleMode && hasChildren; + + const rowFocused = this._focusedValue === node.value; + + return html` +
+
+ { + e.stopPropagation(); + if (hasChildren) this._toggleExpand(node); + }} + role="button" + tabindex=${hasChildren ? 0 : -1} + aria-expanded=${hasChildren && expanded} + > + ${expandIcon + ? html`` + : html``} + + ${singleParent + ? html` + ${node.label} + ${this._renderCount(node)} + ` + : singleMode && isLeaf + ? html` + ) => + this._setSingleLeafValue(node, e.detail)} + > + ${node.label} + + ${this._renderCount(node)} + ` + : html` + ) => + this._toggleNode(node, e.detail)} + > + ${node.label} + + ${this._renderCount(node)} + `} +
+ ${hasChildren && expanded + ? html` +
+ ${node.children!.map(child => this._renderNode(child, depth + 1))} +
+ ` + : ""} +
+ `; + } + + private _renderTree(nodes: TreeNode[]): TemplateResult { + const visible = this._filterVisible(nodes, this._searchText.toLowerCase()); + + return html`
${visible.map(node => this._renderNode(node, 0))}
`; + } + + private _renderAutocompleteList(): TemplateResult { + return html` +
+ ${this._autocompleteList.map( + ({ node, path }) => html` +
this._selectAutocompleteItem(node)} + > + ${this._highlightPath(path, this._searchText)} +
+ ` + )} +
+ `; + } + + private _renderSelectAllRow(): TemplateResult | string { + if (!this.isMultiple || !this.viewSelectAll) return ""; + const { checked, indeterminate } = this._selectAllState; + const count = this.selectedSet.size; + + return html` +
+ ) => this._handleSelectAll(e.detail)} + > + ${this.selectAllText || msg("Select All", { desc: "bl-select: select all text" })} + ${count > 0 + ? html`${msg(str`(${count} selected)`, { desc: "bl-tree-select: selected count" })}` + : ""} + +
+ `; + } + + private _renderPanelContent(): TemplateResult { + if (this._searchText.trim()) { + return this._autocompleteList.length > 0 + ? this._renderAutocompleteList() + : html`
+ ${this.searchNotFoundText ?? + msg("No Result Found", { desc: "bl-tree-select: search no result text" })} +
`; + } + return html`${this._renderSelectAllRow()} ${this._renderTree(this.items)}`; + } + + render(): TemplateResult { + const displayText = this._getDisplayText(); + const searchPh = + this.searchPlaceholder || + this.placeholder || + msg("Search...", { desc: "bl-tree-select: search placeholder" }); + const inputPlaceholder = this._open ? searchPh : displayText || this.placeholder || undefined; + + return html` +
+ ${this.label + ? html`
+ +
` + : ""} +
+ { + if (this._open) e.stopPropagation(); + }} + /> + ${this.isSearchLoading && this._open + ? html`` + : ""} + ${this._hasValue && !this.disabled + ? html` + { + e.stopPropagation(); + this._clearSelection(); + }} + > + ` + : ""} + +
+ +
+ ${this._renderPanelContent()} +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "bl-tree-select": BlTreeSelect; + } +} diff --git a/src/components/tree-select/doc/ADR.md b/src/components/tree-select/doc/ADR.md new file mode 100644 index 000000000..75e796572 --- /dev/null +++ b/src/components/tree-select/doc/ADR.md @@ -0,0 +1,91 @@ +# Tree Select Component + +## Figma Design Document + +[Baklava Design Guide – Tree Select](https://www.figma.com/design/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?node-id=26640-5823&p=f&t=FjujjxTaCNkGYHd4-0) + +## ADR / Issue + +[Component: Tree Select (#1125)](https://github.com/Trendyol/baklava/issues/1125) + +## Overview + +The Tree Select component allows hierarchical selection (e.g. category tree). It supports single and multiple selection, expand/collapse, autocomplete with path display, keyboard navigation, and optional "Select All". + +## Implementation + +### General usage example + +```html + +``` + +Tree data shape (`TreeNode[]`): + +```ts +interface TreeNode { + value: string; + label: string; + count?: number; + children?: TreeNode[]; +} +``` + +### Rules + +- **Single mode (`is-multiple="false"`)**: Only leaf nodes (nodes without children) are selectable; parent nodes have no checkbox and only expand/collapse. +- **Multiple mode**: All nodes have checkboxes; optional "Select All" row when `view-select-all` is set. +- **Autocomplete**: When the user types in the input, the panel shows a filtered list of paths (e.g. `Parent/Child/Leaf`) with the search term highlighted; when there are no matches, the empty state message is shown. +- **Value**: Single mode uses `string | null`; multiple mode uses `string[]`. + +--- + +## API Reference + +### `bl-tree-select` + +| Attribute | Type | Description | Default | +| --------- | ---- | ----------- | ------- | +| `label` | `string` | Label above the input | `""` | +| `placeholder` | `string` | Placeholder for the input | `""` | +| `items` | `TreeNode[]` | Tree data (root nodes with optional `children`, `count`) | `[]` | +| `value` | `string \| string[] \| null` | Selected value(s). Single: one string; multiple: array of strings | `null` | +| `is-multiple` | `boolean` | Multiple selection with checkboxes and Select All; when false, single selection (leaf-only) | `true` | +| `view-select-all` | `boolean` | Show "Select All" row (only when `is-multiple`) | `false` | +| `select-all-text` | `string` | Text for Select All row | `""` (localized fallback: `"Select All"`) | +| `search-placeholder` | `string` | Placeholder for search inside dropdown | `""` (localized fallback: `"Search..."`) | +| `empty-result-text` | `string` | Message when search has no results | `undefined` (localized fallback: `"No Result Found"`) | +| `disabled` | `boolean` | Disables the component | `false` | +| `required` | `boolean` | Marks the field as required | `false` | + +### Events + +| Event | Description | Payload | +| ----- | ----------- | ------- | +| `bl-tree-select-change` | Fired when selection changes | `{ value: string \| string[] \| null }` | + +### Methods + +| Method | Description | +| ------ | ----------- | +| `open()` | Opens the dropdown panel | +| `close()` | Closes the dropdown and clears search text | + +--- + +## States + +- **Default**: Input with placeholder; chevron indicates dropdown. +- **Open**: Panel with tree or autocomplete list; loading spinner shown while user is typing (when search is non-empty). +- **Selected**: Selected item(s) shown in input; clear (X) button visible when not disabled. +- **Empty (no results)**: When search has no matches, panel shows `empty-result-text` in a bordered message area. +- **Disabled**: Component is dimmed and non-interactive; cursor `not-allowed`. diff --git a/src/generated/locales/ar.ts b/src/generated/locales/ar.ts index 00d1aa5e9..28b1385d5 100644 --- a/src/generated/locales/ar.ts +++ b/src/generated/locales/ar.ts @@ -23,5 +23,9 @@ 'sbaace8219b5f4612': `اختر الكل`, 'sc2b31c8d71636c74': str`صفحة ${0}`, 'sf3ff78cc329d3528': `السابق`, +'s837243444fe86fab': str`(${0} selected)`, +'s011cb4b34942843f': `No Result Found`, +'sffa721bb6aa3128d': `Search...`, +'sb4f1dffbb6be6302': `Clear`, }; \ No newline at end of file diff --git a/src/generated/locales/ro.ts b/src/generated/locales/ro.ts index d1fad197d..312202449 100644 --- a/src/generated/locales/ro.ts +++ b/src/generated/locales/ro.ts @@ -23,5 +23,9 @@ 'sbaace8219b5f4612': `Selectează tot`, 'sc2b31c8d71636c74': str`Pagina ${0}`, 'sf3ff78cc329d3528': `Anteriorul`, +'s837243444fe86fab': str`(${0} selected)`, +'s011cb4b34942843f': `No Result Found`, +'sffa721bb6aa3128d': `Search...`, +'sb4f1dffbb6be6302': `Clear`, }; \ No newline at end of file diff --git a/src/generated/locales/tr.ts b/src/generated/locales/tr.ts index d830696b9..567d8a6b5 100644 --- a/src/generated/locales/tr.ts +++ b/src/generated/locales/tr.ts @@ -23,5 +23,9 @@ 'sbaace8219b5f4612': `Tümünü Seç`, 'sc2b31c8d71636c74': str`Sayfa ${0}`, 'sf3ff78cc329d3528': `Önceki`, +'s837243444fe86fab': str`(${0} selected)`, +'s011cb4b34942843f': `No Result Found`, +'sffa721bb6aa3128d': `Search...`, +'sb4f1dffbb6be6302': `Clear`, }; \ No newline at end of file diff --git a/translations/ar.xlf b/translations/ar.xlf index 0bde0cd30..1e50257e4 100644 --- a/translations/ar.xlf +++ b/translations/ar.xlf @@ -72,6 +72,22 @@ صفحة bl-pagination: page number button + + Clear + bl-tree-select: clear selection button + + + No Result Found + bl-tree-select: search no result text + + + Search... + bl-tree-select: search placeholder + + + ( selected) + bl-tree-select: selected count + diff --git a/translations/ro.xlf b/translations/ro.xlf index 1e4d6adbd..a476bad83 100644 --- a/translations/ro.xlf +++ b/translations/ro.xlf @@ -72,6 +72,22 @@ Pagina bl-pagination: page number button + + Clear + bl-tree-select: clear selection button + + + No Result Found + bl-tree-select: search no result text + + + Search... + bl-tree-select: search placeholder + + + ( selected) + bl-tree-select: selected count + diff --git a/translations/tr.xlf b/translations/tr.xlf index 31af69489..cfc737a14 100644 --- a/translations/tr.xlf +++ b/translations/tr.xlf @@ -72,6 +72,22 @@ Sayfa bl-pagination: page number button + + Clear + bl-tree-select: clear selection button + + + No Result Found + bl-tree-select: search no result text + + + Search... + bl-tree-select: search placeholder + + + ( selected) + bl-tree-select: selected count +