@m1z23r/ngx-query-builder
v1.0.1
Published
A flexible, schema-driven query builder component for Angular with full customization support
Maintainers
Readme
@m1z23r/ngx-query-builder
A flexible, schema-driven query builder component for Angular 17+ with full customization support. Built with signals and standalone components.
Features
- Schema-driven configuration for fields and operators
- Built-in operators for common use cases (equals, contains, greater than, etc.)
- Nested groups with AND/OR conjunctions and NOT negation
- 8 customization points for complete UI control
- Two-way binding with Angular signals
- Accessible by default (WCAG AA compliant)
- Zero dependencies beyond Angular
- Tree-shakeable
Installation
npm install @m1z23r/ngx-query-builder
# or
yarn add @m1z23r/ngx-query-builderQuick Start
1. Import the component
import { Component } from '@angular/core';
import {
QueryBuilderComponent,
QueryGroup,
createSchema,
createEmptyGroup,
} from '@m1z23r/ngx-query-builder';
@Component({
selector: 'app-root',
imports: [QueryBuilderComponent],
template: `
<qb-query-builder [schema]="schema" [(value)]="query" />
<pre>{{ query | json }}</pre>
`,
})
export class AppComponent {
schema = createSchema({
fields: [
{ key: 'name', label: 'Name', type: 'text' },
{ key: 'email', label: 'Email', type: 'text' },
{ key: 'age', label: 'Age', type: 'number' },
{ key: 'created_at', label: 'Created At', type: 'date' },
],
});
query: QueryGroup = createEmptyGroup();
}That's it! You now have a fully functional query builder with default UI.
Schema Configuration
The schema defines what fields users can filter on and what operators are available.
Field Definition
interface FieldDef {
key: string; // Backend field identifier
label: string; // Display label
type: FieldType; // 'text' | 'number' | 'date' | 'bool'
options?: FieldOption[]; // For select/dropdown fields
nullable?: boolean; // Enables is_null/is_not_null operators
}
interface FieldOption {
value: string;
label: string;
}Example with Options
const schema = createSchema({
fields: [
{ key: 'name', label: 'Name', type: 'text' },
{ key: 'age', label: 'Age', type: 'number', nullable: true },
{ key: 'created_at', label: 'Created At', type: 'date', nullable: true },
{ key: 'is_active', label: 'Active', type: 'bool' },
{
key: 'status',
label: 'Status',
type: 'text',
options: [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'pending', label: 'Pending' },
],
},
],
});Built-in Operators
The library includes 13 built-in operators via the OPERATORS constant:
| Key | Label | Supported Types | Value Kind |
|-----|-------|-----------------|------------|
| eq | equals | text, number, date, bool | single |
| neq | not equals | text, number, date, bool | single |
| gt | greater than | number, date | single |
| gte | greater than or equals | number, date | single |
| lt | less than | number, date | single |
| lte | less than or equals | number, date | single |
| contains | contains | text | single |
| starts_with | starts with | text | single |
| ends_with | ends with | text | single |
| in | in | text, number | multi |
| nin | not in | text, number | multi |
| is_null | is null | all (requires nullable: true) | none |
| is_not_null | is not null | all (requires nullable: true) | none |
Using Default Operators
import { createSchema } from '@m1z23r/ngx-query-builder';
// Uses OPERATORS by default
const schema = createSchema({
fields: [...],
});Custom Operators
You can extend or replace the default operators:
import { createSchema, OPERATORS } from '@m1z23r/ngx-query-builder';
const schema = createSchema({
fields: [...],
operators: {
...OPERATORS, // Include defaults
between: {
label: 'between',
supportedTypes: ['number', 'date'],
valueKind: 'multi',
},
regex: {
label: 'matches regex',
supportedTypes: ['text'],
valueKind: 'single',
},
},
});Custom Input Types
For custom operators like between, you'll want custom input components. Use resolveValueInput:
const schema = createSchema({
fields: [...],
operators: {
...OPERATORS,
between: {
label: 'between',
supportedTypes: ['number', 'date'],
valueKind: 'multi',
},
},
resolveValueInput: (field, operator, operatorKey) => {
if (operatorKey === 'between') {
return field.type === 'date' ? 'date-range' : 'number-range';
}
return null; // Use default resolution
},
});Query Structure
The query builder produces a tree structure:
interface QueryGroup {
type: 'group';
conjunction: 'and' | 'or';
negated: boolean;
children: QueryNode[];
}
interface QueryCondition {
type: 'condition';
field: string | null;
operator: string | null;
value: unknown;
}
type QueryNode = QueryGroup | QueryCondition;Example Output
{
"type": "group",
"conjunction": "and",
"negated": false,
"children": [
{
"type": "condition",
"field": "name",
"operator": "contains",
"value": "John"
},
{
"type": "group",
"conjunction": "or",
"negated": false,
"children": [
{
"type": "condition",
"field": "age",
"operator": "gte",
"value": 18
},
{
"type": "condition",
"field": "status",
"operator": "eq",
"value": "active"
}
]
}
]
}Template Customization
The query builder provides 8 customization points using structural directives. Each accepts an ng-template with a typed context.
Condition-Level Directives
Field Selector
<qb-query-builder [schema]="schema" [(value)]="query">
<ng-template qbFieldSelector let-field let-fields="fields" let-onChange="onChange">
<select [value]="field" (change)="onChange($any($event.target).value)">
<option value="">Select field...</option>
@for (f of fields; track f.key) {
<option [value]="f.key">{{ f.label }}</option>
}
</select>
</ng-template>
</qb-query-builder>Context:
interface FieldSelectorContext {
$implicit: string | null; // Current field key
fields: FieldDef[]; // Available fields
onChange: (key: string) => void; // Update handler
}Operator Selector
<ng-template qbOperatorSelector let-operator let-operators="operators" let-onChange="onChange">
<select [value]="operator" (change)="onChange($any($event.target).value)">
<option value="">Select operator...</option>
@for (op of operators; track op[0]) {
<option [value]="op[0]">{{ op[1].label }}</option>
}
</select>
</ng-template>Context:
interface OperatorSelectorContext {
$implicit: string | null; // Current operator key
operators: [string, OperatorDef][]; // Available operators
onChange: (key: string) => void; // Update handler
}Value Input
Use qbValueInput with an input type to customize specific value inputs:
<!-- Custom text input -->
<ng-template [qbValueInput]="'text-input'" let-value let-onChange="onChange">
<input type="text" [value]="value ?? ''" (input)="onChange($any($event.target).value)" />
</ng-template>
<!-- Custom date input -->
<ng-template [qbValueInput]="'date-picker'" let-value let-onChange="onChange">
<input type="date" [value]="value ?? ''" (change)="onChange($any($event.target).value)" />
</ng-template>
<!-- Custom range input for "between" operator -->
<ng-template [qbValueInput]="'number-range'" let-value let-onChange="onChange">
<app-number-range [value]="value" (valueChange)="onChange($event)" />
</ng-template>Context:
interface ValueInputContext {
$implicit: unknown; // Current value
inputType: string; // Resolved input type
field: FieldDef; // Selected field
operator: OperatorDef; // Selected operator
operatorKey: string | null; // Operator key
options: FieldOption[]; // Field options (if any)
placeholder: string; // Suggested placeholder
onChange: (value: unknown) => void; // Update handler
}Remove Button
<ng-template qbRemoveButton let-onRemove="onRemove">
<button type="button" (click)="onRemove()">Remove</button>
</ng-template>Context:
interface RemoveButtonContext {
$implicit: void;
onRemove: () => void;
}Group-Level Directives
Conjunction Selector
<ng-template qbConjunctionSelector let-conjunction let-onChange="onChange">
<button
type="button"
[class.active]="conjunction === 'and'"
(click)="onChange('and')">
AND
</button>
<button
type="button"
[class.active]="conjunction === 'or'"
(click)="onChange('or')">
OR
</button>
</ng-template>Context:
interface ConjunctionSelectorContext {
$implicit: 'and' | 'or';
onChange: (conjunction: 'and' | 'or') => void;
}Negation Toggle
<ng-template qbNegationToggle let-negated let-onChange="onChange">
<button
type="button"
[class.active]="negated"
(click)="onChange(!negated)">
NOT
</button>
</ng-template>Context:
interface NegationToggleContext {
$implicit: boolean;
onChange: (negated: boolean) => void;
}Add Buttons
<ng-template qbAddButtons let-onAddCondition="onAddCondition" let-onAddGroup="onAddGroup">
<button type="button" (click)="onAddCondition()">+ Condition</button>
<button type="button" (click)="onAddGroup()">+ Group</button>
</ng-template>Context:
interface AddButtonsContext {
$implicit: void;
onAddCondition: () => void;
onAddGroup: () => void;
}Remove Group Button
<ng-template qbRemoveGroupButton let-depth="depth" let-onRemove="onRemove">
@if (depth > 0) {
<button type="button" (click)="onRemove()">Remove Group</button>
}
</ng-template>Context:
interface RemoveGroupButtonContext {
$implicit: void;
depth: number; // Nesting depth (0 = root)
onRemove: () => void;
}Full Customization Example
@Component({
selector: 'app-custom-query-builder',
imports: [
QueryBuilderComponent,
QbFieldSelectorDirective,
QbOperatorSelectorDirective,
QbValueInputDirective,
QbRemoveButtonDirective,
QbConjunctionSelectorDirective,
QbNegationToggleDirective,
QbAddButtonsDirective,
QbRemoveGroupButtonDirective,
],
template: `
<qb-query-builder [schema]="schema" [(value)]="query">
<!-- Field selector -->
<ng-template qbFieldSelector let-field let-fields="fields" let-onChange="onChange">
<app-custom-field-select
[value]="field"
[fields]="fields"
(valueChange)="onChange($event)"
/>
</ng-template>
<!-- Operator selector -->
<ng-template qbOperatorSelector let-op let-operators="operators" let-onChange="onChange">
<app-custom-operator-select
[value]="op"
[operators]="operators"
(valueChange)="onChange($event)"
/>
</ng-template>
<!-- Text input -->
<ng-template [qbValueInput]="'text-input'" let-value let-onChange="onChange">
<app-custom-text-input [value]="value" (valueChange)="onChange($event)" />
</ng-template>
<!-- Number input -->
<ng-template [qbValueInput]="'number-input'" let-value let-onChange="onChange">
<app-custom-number-input [value]="value" (valueChange)="onChange($event)" />
</ng-template>
<!-- Date input -->
<ng-template [qbValueInput]="'date-picker'" let-value let-onChange="onChange">
<app-custom-date-input [value]="value" (valueChange)="onChange($event)" />
</ng-template>
<!-- Remove condition button -->
<ng-template qbRemoveButton let-onRemove="onRemove">
<app-custom-remove-button (click)="onRemove()" />
</ng-template>
<!-- Conjunction selector (AND/OR) -->
<ng-template qbConjunctionSelector let-conj let-onChange="onChange">
<app-custom-conjunction [value]="conj" (valueChange)="onChange($event)" />
</ng-template>
<!-- Negation toggle (NOT) -->
<ng-template qbNegationToggle let-negated let-onChange="onChange">
<app-custom-negation [value]="negated" (valueChange)="onChange($event)" />
</ng-template>
<!-- Add buttons -->
<ng-template qbAddButtons let-onAddCondition="onAddCondition" let-onAddGroup="onAddGroup">
<app-custom-add-buttons
(addCondition)="onAddCondition()"
(addGroup)="onAddGroup()"
/>
</ng-template>
<!-- Remove group button -->
<ng-template qbRemoveGroupButton let-depth="depth" let-onRemove="onRemove">
<app-custom-remove-group [depth]="depth" (click)="onRemove()" />
</ng-template>
</qb-query-builder>
`,
})
export class CustomQueryBuilderComponent {
schema = createSchema({ fields: [...] });
query = createEmptyGroup();
}API Reference
QueryBuilderComponent
Selector: qb-query-builder
| Input/Output | Type | Description |
|--------------|------|-------------|
| schema | input.required<QuerySchema>() | Schema configuration |
| value | model<QueryGroup>() | Two-way bindable query state |
| disabled | model<boolean>() | Disable all inputs |
Utility Functions
// Create a schema from options
createSchema(options: CreateSchemaOptions): QuerySchema
// Create an empty condition
createEmptyCondition(): QueryCondition
// Returns: { type: 'condition', field: null, operator: null, value: null }
// Create an empty group with one condition
createEmptyGroup(): QueryGroup
// Returns: { type: 'group', conjunction: 'and', negated: false, children: [emptyCondition] }
// Type guards
isCondition(node: QueryNode): node is QueryCondition
isGroup(node: QueryNode): node is QueryGroupConstants
// Built-in input type identifiers
INPUT_TYPES = {
TEXT: 'text-input',
NUMBER: 'number-input',
DATE: 'date-picker',
BOOLEAN: 'boolean-toggle',
SELECT: 'select',
MULTI_SELECT: 'multi-select',
}
// Built-in operators
OPERATORS = { eq, neq, gt, gte, lt, lte, contains, starts_with, ends_with, in, nin, is_null, is_not_null }Styling
The default UI uses minimal inline styles. You can style the component using CSS classes:
| Class | Element |
|-------|---------|
| .qb-wrapper | Root fieldset |
| .qb-group | Group container |
| .qb-group-header | Group header (conjunction, negation, remove) |
| .qb-group-children | Children container |
| .qb-condition | Condition row |
| .qb-add-buttons | Add condition/group buttons |
For full control, use template customization to render your own markup.
Accessibility
The default UI includes:
- Semantic HTML (
<fieldset>,<legend>,<label>) - Screen-reader-only labels (
.qb-sr-only) - Proper focus management
- Keyboard navigation support
- ARIA attributes where needed
TypeScript Support
All types are exported for full TypeScript support:
import type {
FieldType,
ValueKind,
InputType,
OperatorKey,
Conjunction,
FieldOption,
OperatorDef,
FieldDef,
QueryCondition,
QueryGroup,
QueryNode,
QuerySchema,
CreateSchemaOptions,
BuiltinOperatorKey,
// Template contexts
FieldSelectorContext,
OperatorSelectorContext,
ValueInputContext,
RemoveButtonContext,
ConjunctionSelectorContext,
NegationToggleContext,
AddButtonsContext,
RemoveGroupButtonContext,
} from '@m1z23r/ngx-query-builder';Browser Support
Supports all browsers that Angular 17+ supports.
Claude Code Integration
If you use Claude Code, add the following to your project's CLAUDE.md file to help Claude work effectively with this library:
## @m1z23r/ngx-query-builder
Schema-driven query builder for Angular 17+. Standalone components, signals-based, fully customizable.
### Component Usage
```typescript
import {
QueryBuilderComponent,
QueryGroup,
createSchema,
createEmptyGroup,
OPERATORS,
} from '@m1z23r/ngx-query-builder';
```
- Selector: `<qb-query-builder [schema]="schema" [(value)]="query" />`
- `schema` is a required input (`QuerySchema`), created via `createSchema()`
- `value` is a two-way model signal (`QueryGroup`), initialized via `createEmptyGroup()`
- `disabled` is an optional two-way model signal (`boolean`)
### Schema Configuration
```typescript
const schema = createSchema({
fields: [
{ key: 'name', label: 'Name', type: 'text' },
{ key: 'age', label: 'Age', type: 'number', nullable: true },
{ key: 'created_at', label: 'Created At', type: 'date' },
{ key: 'is_active', label: 'Active', type: 'bool' },
{ key: 'status', label: 'Status', type: 'text', options: [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
]},
],
operators: { ...OPERATORS }, // Optional, defaults to built-in OPERATORS
resolveValueInput: (field, operator, operatorKey) => {
// Return custom input type string or null for default resolution
return null;
},
});
```
- `FieldDef.type`: `'text' | 'number' | 'date' | 'bool'`
- `FieldDef.options`: Adds select/multi-select dropdown behavior
- `FieldDef.nullable`: Enables `is_null` / `is_not_null` operators for that field
### Built-in Operators (OPERATORS constant)
`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `starts_with`, `ends_with`, `in`, `nin`, `is_null`, `is_not_null`
Custom operators can be added alongside defaults: `operators: { ...OPERATORS, between: { label: 'between', supportedTypes: ['number', 'date'], valueKind: 'multi' } }`
### Built-in Input Types (INPUT_TYPES constant)
`text-input`, `number-input`, `date-picker`, `boolean-toggle`, `select`, `multi-select`
These are resolved automatically based on field type and operator. Override with `resolveValueInput` in schema options.
### Query Data Structure
```typescript
// Output is a tree of groups and conditions:
interface QueryGroup {
type: 'group';
conjunction: 'and' | 'or';
negated: boolean;
children: QueryNode[];
}
interface QueryCondition {
type: 'condition';
field: string | null;
operator: string | null;
value: unknown;
}
type QueryNode = QueryGroup | QueryCondition;
```
### Template Customization (8 directives)
All customizations use `ng-template` with typed contexts inside `<qb-query-builder>`:
**Condition-level:**
- `<ng-template qbFieldSelector let-field let-fields="fields" let-onChange="onChange">` — Custom field dropdown
- `<ng-template qbOperatorSelector let-operator let-operators="operators" let-onChange="onChange">` — Custom operator dropdown. `operators` is `[string, OperatorDef][]`
- `<ng-template [qbValueInput]="'input-type-name'" let-value let-onChange="onChange">` — Custom value input for a specific input type. Additional context: `field`, `operator`, `operatorKey`, `options`, `placeholder`, `inputType`
- `<ng-template qbRemoveButton let-onRemove="onRemove">` — Custom remove condition button
**Group-level:**
- `<ng-template qbConjunctionSelector let-conjunction let-onChange="onChange">` — Custom AND/OR toggle
- `<ng-template qbNegationToggle let-negated let-onChange="onChange">` — Custom NOT toggle
- `<ng-template qbAddButtons let-onAddCondition="onAddCondition" let-onAddGroup="onAddGroup">` — Custom add buttons
- `<ng-template qbRemoveGroupButton let-depth="depth" let-onRemove="onRemove">` — Custom remove group button. `depth` is 0 for root group
**Imports for directives:** `QbFieldSelectorDirective`, `QbOperatorSelectorDirective`, `QbValueInputDirective`, `QbRemoveButtonDirective`, `QbConjunctionSelectorDirective`, `QbNegationToggleDirective`, `QbAddButtonsDirective`, `QbRemoveGroupButtonDirective`
### Utility Functions
- `createSchema(options)` — Create a `QuerySchema` from fields, operators, and optional `resolveValueInput`
- `createEmptyGroup()` — Returns a `QueryGroup` with one empty condition
- `createEmptyCondition()` — Returns a `QueryCondition` with all fields null
- `isCondition(node)` / `isGroup(node)` — Type guards for `QueryNode`
### Key CSS Classes (for styling defaults)
`.qb-wrapper`, `.qb-group`, `.qb-nested`, `.qb-negated`, `.qb-group-header`, `.qb-condition-row`, `.qb-condition`, `.qb-add-btn`, `.qb-remove-btn`, `.qb-toggle-buttons`, `.qb-active`, `.qb-sr-only`License
MIT
