Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.2] - 2026-08-02

### Fixed

- Two options sharing a `value` no longer render the same content. Rendered row content was cached by option value, so with `duplicateValuePolicy` left at its default (warn, not reject) the second option displayed the first one's label. Content is now cached against the option object itself.
- Reopening an AJAX-backed select after a search no longer shows the previous query's results under an empty search box. Closing clears the search box, so the loaded page is now discarded with it and refetched on reopen — normally served from the remote cache without an extra request. Closing also retires the request that query belongs to, so one still in flight cannot land its filtered page afterwards and suppress the reload. Selects whose search box was already empty are unaffected and still do not refetch.

### Changed

- Row `<li>` recycling is keyed by option value instead of value-plus-row-index. Filtering shifts every index below the first change, which previously invalidated the whole element cache on each keystroke; reuse across a narrowing query goes from 0% to ~18% in a 2,000-option list. Rows are claimed at most once per render, so duplicate values still render as separate rows.
- Removed `scoreOption()`, an unused duplicate of `SearchIndex.score()`. It was never part of the public API and was already tree-shaken out of the published bundle, but left two copies of the scoring rules to keep in sync by hand.
- Angular and Svelte wrapper packages are no longer planned and have been dropped from the roadmap. Both frameworks mount Forge Select directly; `docs/examples.md` now documents the Angular approach alongside the existing Svelte one.

Comment on lines +10 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the Angular documentation change under [Unreleased].

The new Angular integration guidance is documented in docs/examples.md, but the changelog records it only under [0.7.2]. Add a concise entry under [Unreleased] before the versioned release.

As per coding guidelines, docs/**/*.md requires an entry under the Unreleased section of CHANGELOG.md when behavior changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 10 - 22, Add a concise Changed entry under the
[Unreleased] section of CHANGELOG.md, before the [0.7.2] release, noting that
docs/examples.md now includes Angular integration guidance for mounting Forge
Select directly.

Source: Coding guidelines

## [0.7.1] - 2026-08-02

### Fixed
Expand Down Expand Up @@ -183,7 +196,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Website**: landing page, rendered documentation, interactive playground, and feature demo at <https://cmm-cmm.github.io/ForgeSelect/>.
- **Documentation**: API reference, examples, playground guide, Select2 migration guide, benchmarks methodology, and plugin development guide under `docs/`.

[Unreleased]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.7.1...HEAD
[Unreleased]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.7.2...HEAD
[0.7.2]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.7.1...v0.7.2
[0.7.1]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.5.0...v0.6.0
Expand Down
8 changes: 2 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,6 @@ Browse the documentation website at **<https://forgeselect.konexforge.com/docs/>
- Internationalization (en/vi built in, custom string tables)
- TypeScript Support (written in strict TypeScript, ships `.d.ts`)

> Planned/in-progress capabilities — Angular/Svelte wrappers — are tracked in the [Roadmap](#roadmap) below and intentionally not listed above as shipped features.

## Installation

```bash
Expand Down Expand Up @@ -189,8 +187,8 @@ Forge Select is vanilla TypeScript/JavaScript, so it can be mounted inside any f
- Vanilla JavaScript
- React — via [`forge-select-react`](./packages/react/README.md) (`ForgeSelectReact` component, controlled `value`/`onChange`)
- Vue — via [`forge-select-vue`](./packages/vue/README.md) (`ForgeSelectVue` component, `v-model` support)
- Angular — mount manually for now; a dedicated wrapper is on the [Roadmap](#roadmap)
- Svelte — mount manually for now; a dedicated wrapper is on the [Roadmap](#roadmap)
- Angular — mount manually (see [`docs/examples.md`](./docs/examples.md))
- Svelte — mount manually (see [`docs/examples.md`](./docs/examples.md))
- Next.js
- Nuxt
- Astro
Expand Down Expand Up @@ -221,8 +219,6 @@ Run `npm run bench` for a reproducible JSON baseline covering bundle size, initi
- [x] CSS Variables
- [x] React Component
- [x] Vue Component
- [ ] Angular Component
- [ ] Svelte Component

## Plugin Development Guide

Expand Down
40 changes: 40 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,46 @@ onBeforeUnmount(() => select?.destroy());
</select>
```

## Angular

There is no Angular wrapper package; mount Forge Select on a template ref and
destroy it with the component. Run it outside Angular's zone if you don't want
change detection on every internal event.

```ts
import { Component, ElementRef, OnDestroy, AfterViewInit, ViewChild, NgZone } from "@angular/core";
import ForgeSelect from "forge-select";

@Component({
selector: "app-country-select",
standalone: true,
template: `<select #host></select>`,
})
export class CountrySelectComponent implements AfterViewInit, OnDestroy {
@ViewChild("host") host!: ElementRef<HTMLSelectElement>;
private select?: ForgeSelect;

constructor(private zone: NgZone) {}

ngAfterViewInit(): void {
this.zone.runOutsideAngular(() => {
this.select = new ForgeSelect(this.host.nativeElement, {
searchable: true,
data: [
{ value: "vn", label: "Vietnam" },
{ value: "jp", label: "Japan" },
],
});
this.select.on("change", (value) => this.zone.run(() => console.log(value)));
});
}

ngOnDestroy(): void {
this.select?.destroy();
}
}
```

## See also

- [API Reference](./api-reference.md)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "forge-select",
"version": "0.7.1",
"version": "0.7.2",
"description": "A modern, lightweight, highly customizable replacement for Select2.",
"keywords": [
"select",
Expand Down
4 changes: 2 additions & 2 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
{ "@type": "Question", "name": "Is Forge Select accessible?",
"acceptedAnswer": { "@type": "Answer", "text": "Yes. Forge Select implements ARIA combobox/listbox semantics, aria-activedescendant, and full keyboard navigation out of the box." } },
{ "@type": "Question", "name": "Does Forge Select work with React, Vue, Angular, or Svelte?",
"acceptedAnswer": { "@type": "Answer", "text": "Forge Select is framework-agnostic vanilla JavaScript/TypeScript, so it can be mounted inside any framework today. Official wrapper packages are available for React (forge-select-react) and Vue (forge-select-vue) with controlled value/v-model support; Angular and Svelte wrappers are on the roadmap." } },
"acceptedAnswer": { "@type": "Answer", "text": "Forge Select is framework-agnostic vanilla JavaScript/TypeScript, so it can be mounted inside any framework today. Official wrapper packages are available for React (forge-select-react) and Vue (forge-select-vue) with controlled value/v-model support. Angular and Svelte have no wrapper package; mount Forge Select directly in a component." } },
{ "@type": "Question", "name": "Can I reorder selected tags by dragging them?",
"acceptedAnswer": { "@type": "Answer", "text": "Yes. Set sortable: true on a multiple select to let users drag tags into a new order with mouse, touch, or pen, or use Alt+Left/Alt+Right when a tag has keyboard focus." } },
{ "@type": "Question", "name": "Is Forge Select production-ready?",
Expand Down Expand Up @@ -187,7 +187,7 @@ <h2 style="text-align:center">Frequently asked questions</h2>
</details>
<details class="faq-item">
<summary>Does Forge Select work with React, Vue, Angular, or Svelte?</summary>
<p>Forge Select is framework-agnostic vanilla JavaScript/TypeScript, so it can be mounted inside any framework today. Official wrapper packages are available for React (<a href="https://www.npmjs.com/package/forge-select-react"><code>forge-select-react</code></a>) and Vue (<a href="https://www.npmjs.com/package/forge-select-vue"><code>forge-select-vue</code></a>) with controlled <code>value</code>/<code>v-model</code> support; Angular and Svelte wrappers are on the <a href="https://github.com/cmm-cmm/ForgeSelect#roadmap">roadmap</a>.</p>
<p>Forge Select is framework-agnostic vanilla JavaScript/TypeScript, so it can be mounted inside any framework today. Official wrapper packages are available for React (<a href="https://www.npmjs.com/package/forge-select-react"><code>forge-select-react</code></a>) and Vue (<a href="https://www.npmjs.com/package/forge-select-vue"><code>forge-select-vue</code></a>) with controlled <code>value</code>/<code>v-model</code> support. Angular and Svelte have no wrapper package; mount Forge Select directly in a component.</p>
</details>
<details class="faq-item">
<summary>Can I reorder selected tags by dragging them?</summary>
Expand Down
64 changes: 58 additions & 6 deletions src/ForgeSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,13 @@ export default class ForgeSelect {
private highlightedIndex = -1;
private typeaheadBuffer = "";
private typeaheadTimer: ReturnType<typeof setTimeout> | null = null;
private rowContentCache = new Map<string, Node>();
// Keyed by the option object, not its value: duplicateValuePolicy only warns
// by default, so two options can share a value while carrying different
// labels, and a value-keyed cache would render the first one's content for
// both. Still explicitly bounded — a WeakMap would not collect anything while
// `data` holds every key, so scrolling a long list would retain a detached
// node per option visited.
private rowContentCache = new Map<Option, Node>();
private rowElementCache = new Map<string, HTMLLIElement>();
private rowHeightCache = new Map<string, number>();
private rowOffsetsCache: number[] | null = null;
Expand Down Expand Up @@ -353,8 +359,29 @@ export default class ForgeSelect {
this.typeaheadBuffer = "";
this.highlightedIndex = -1;
if (this.searchInput) {
const hadQuery = this.query !== "";
this.searchInput.value = "";
this.query = "";
// A remote list holds the page fetched for the query just cleared, so
// reopening would show a filtered list under an empty search box. Local
// lists re-filter their own `data` on the next render and need nothing.
// The remote cache normally serves the refetch without a new request.
if (hadQuery && this.opts.ajax) {
// Retire the request the cleared query belongs to as well. Left running,
// it would land its filtered page and set remoteLoaded, so the reopen
// would skip the empty-query load and show exactly the stale rows this
// is meant to prevent.
this.ajaxRequestId += 1;
if (this.ajaxTimer) {
clearTimeout(this.ajaxTimer);
this.ajaxTimer = null;
}
this.ajaxController?.abort();
this.ajaxController = null;
this.setLoading(false);
this.loadingMore = false;
this.remoteLoaded = false;
}
}

this.emitter.emit("close");
Expand Down Expand Up @@ -1556,12 +1583,30 @@ export default class ForgeSelect {
this.rowOffsetsCache = null;
}

/** Identifies a row *position*, which is what measured heights are tied to. */
private rowKey(row: Row, index: number): string {
if (row.kind === "option") return `option:${row.option.value}:${index}`;
if (row.kind === "group") return `group:${row.label}:${index}`;
return `${row.kind}:${index}`;
}

/**
* Identifies a row's *content* for `<li>` recycling, deliberately without the
* row index: filtering shifts every index below the first change, so an
* index-keyed element cache misses on every keystroke — exactly when the list
* re-renders most. renderRow() rewrites the element completely, so reusing it
* at a new position is safe.
*
* Keys are not guaranteed unique within a render — duplicateValuePolicy
* defaults to warning rather than rejecting duplicate values — so callers
* must not hand the same element to two rows of one render.
*/
private rowElementKey(row: Row): string {
if (row.kind === "option") return `option:${row.option.value}`;
if (row.kind === "group") return `group:${row.label}`;
return row.kind;
}

private measuredRowHeight(index: number): number {
return this.opts.variableItemHeight
? (this.rowHeightCache.get(this.rowKey(this.rows[index], index)) ?? this.opts.itemHeight)
Expand Down Expand Up @@ -1652,9 +1697,16 @@ export default class ForgeSelect {
// flush overall (unavoidable — real heights are needed), but only once
// per renderRows() call instead of once per row.
const appended: HTMLLIElement[] = [];
// Two rows of one render can share an element key (duplicate option values
// are warned about, not rejected). Appending one element twice would move
// it rather than add it, silently dropping a row, so each element is
// claimed at most once per render and later rows fall back to a new one.
const claimed = new Set<HTMLLIElement>();
for (let i = start; i < end; i++) {
const key = this.rowKey(this.rows[i], i);
const element = this.renderRow(this.rows[i], this.rowElementCache.get(key));
const key = this.rowElementKey(this.rows[i]);
const cached = this.rowElementCache.get(key);
const element = this.renderRow(this.rows[i], cached && !claimed.has(cached) ? cached : undefined);
claimed.add(element);
this.rowElementCache.set(key, element);
if (this.rowElementCache.size > ROW_CACHE_LIMIT) {
const oldest = this.rowElementCache.keys().next().value as string;
Expand Down Expand Up @@ -1837,17 +1889,17 @@ export default class ForgeSelect {
}
return holder;
}
let cached = this.rowContentCache.get(option.value);
let cached = this.rowContentCache.get(option);
if (!cached) {
const holder = document.createElement("span");
holder.className = "forge-select__option-content";
renderOptionContent(holder, option, this.opts.templateResult, "row", this.opts.sanitizeTemplate);
if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
// FIFO eviction keeps memory bounded on very large lists.
const oldest = this.rowContentCache.keys().next().value as string;
const oldest = this.rowContentCache.keys().next().value as Option;
this.rowContentCache.delete(oldest);
}
this.rowContentCache.set(option.value, holder);
this.rowContentCache.set(option, holder);
cached = holder;
}
return cached.cloneNode(true);
Expand Down
16 changes: 0 additions & 16 deletions src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,6 @@ export function getSearchField(option: Option, field: SearchField): string {
return value == null ? "" : String(value);
}

export function scoreOption(option: Option, query: string, config: SearchConfig): number {
const normalizedQuery = normalizeSearchText(query.trim(), config.accentInsensitive);
if (!normalizedQuery) return 1;
if (config.scorer) return config.scorer(option, query.trim(), normalizedQuery);
const haystacks = config.fields.map((field) =>
normalizeSearchText(getSearchField(option, field), config.accentInsensitive),
);
const tokens = config.tokenSearch ? normalizedQuery.split(/\s+/).filter(Boolean) : [normalizedQuery];
if (!tokens.every((token) => haystacks.some((field) => field.includes(token)))) return 0;
const label = haystacks[config.fields.indexOf("label")];
if (label === normalizedQuery) return 4;
if (label?.startsWith(normalizedQuery)) return 3;
if (label?.includes(normalizedQuery)) return 2;
return 1;
}

export class SearchIndex {
private cache = new WeakMap<Option, Map<string, string[]>>();

Expand Down
Loading
Loading