diff --git a/AGENTS.md b/AGENTS.md index 359b5e7..ebfe854 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ Runes only. No Svelte 4 idioms — no `export let`, no `$:`, no stores for compo - `$derived` / `$derived.by` instead of `useMemo`. Fine-grained reactivity means manual memoization is almost never needed — don't port React's memoization. - `$state` for local mutable state; `$effect` only as a last resort (prefer `$derived`) - Callback props (`onQueryChange`), not events -- `{#snippet}` / `{@render}` for slot-like customization instead of `controlElements` component maps where it reads better; keep an escape hatch for passing custom components +- `{#snippet}` / `{@render}` for slot-like customization: each control is a top-level snippet prop, with the `controls` object as the escape hatch for passing components. Snippets and components are indistinguishable at runtime, so a snippet used as a control is wrapped as `{ snippet }` (see `internal/Control.svelte`) — never invoke a compiled component or snippet by hand - `setContext`/`getContext` for cross-tree config instead of prop drilling — but context is set once at init, so pass a getter or a `$state` object if the value must stay reactive ### TypeScript diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab5d0d..bce837a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Query state is now owned entirely by Svelte runes. `QueryManager` is gone from this package: core's pure functions (`add`/`remove`/`update`/`move`, `createQueryActions`, `prepareOptionList`, `deriveRuleContext`, `shouldCoalesce`) supply the logic, and the reactive graph lives in `$state`/`$derived`. `createQueryBuilderState` contains no `$effect` at all, and no longer relies on deep-compare, live closures, a config-version counter, subscription mirroring, or try/catch around immer's freeze. +Control elements are now composed the Svelte way. Each of the 24 control names is a top-level snippet prop, so `{#snippet valueEditor(props)}` works as a direct child of ``, and the internal component-ABI trick that used to make snippets and components interchangeable is gone. + ### Removed +- **Breaking:** the `controlElements` prop, replaced by `controls` (see below). +- **Breaking:** `ControlSnippets` and the 24 `${key}Snippet` props it generated. There is now one name per control. +- `snippetToComponent` and `SnippetHost`, which fabricated a component from a snippet by invoking a compiled `.svelte` module through Svelte's undocumented `(anchor | payload, props)` calling convention, plus the `WeakMap` that kept the fabricated components identity-stable. Nothing in the package relies on Svelte internals now. +- `nullComponent`. A `null` control short-circuits in the renderer instead of rendering an empty component. - **Breaking:** the `manager` prop and `schema.manager`. External `QueryManager` control was speculative, unused, and the one thing runes cannot own. Hold the query yourself and use `bind:query`, or `query` + `onQueryChange`. - **Breaking:** `enableMountQueryChange`. Its behavior is now derived from first principles — see below. - `createRuleContext` and `createRuleGroupContext`, along with the `Derived` (`{ readonly current: T }`) wrapper type. `createRuleParts`/`createRuleGroupParts` are the supported path and return getters directly. - `createActions`, superseded by core's `createQueryActions`. +### Added + +- Top-level snippet props for every control: `valueEditor`, `removeRuleAction`, `ruleGroup`, `actionElement`, `valueSelector`, and so on. A snippet declared inside a component's tags only becomes a prop when the name is top-level, which is what makes the idiomatic form reachable. +- `controls`, the bulk object form, for configuration assembled programmatically. It accepts components, `null`, and snippets wrapped as `{ snippet }`. + ### Changed +- **Breaking:** control elements are typed `Control

= Component

| { snippet: Snippet<[P]> }`, or `null`. Snippets and components are both plain functions at runtime with no reliable way to tell them apart, so a snippet used as a control carries a wrapper object; the top-level snippet props wrap automatically. `ControlElementsProp` is now `ControlsProp`, and `ControlPropsMap` is the single source of truth for control names and their prop types. +- **Breaking:** `Controls` entries are uniformly nullable — including `actionElement`, `valueSelector`, `rule`, and `ruleGroup` — with `null` meaning "render nothing". Every key is always present after resolution. +- **Breaking:** `selectorComponent`, `numericEditorComponent`, and `InlineCombinatorProps.component` accept a `Control`, so a `valueSelector` supplied as a snippet applies inside `ValueEditor` and `MatchModeEditor` too. +- **Breaking:** `mergeControlElements` is now `mergeControls(controls, snippets, contextControls, contextSnippets, defaults)`. +- A query builder publishes its _resolved_ controls through context, so a nested (subquery) builder inherits what the outer one resolved and overrides it per key with its own props. - **Breaking:** `schema.manager` is replaced by `schema.history` — `canUndo`, `canRedo`, `undo`, `redo`, `clear`. Backed by getters, so reads stay reactive without dependency pokes. - **Breaking:** the `skipHook` option is renamed `skipValueReset` on `MatchModeEditor` and the value-editor reset. It suppresses the value reset, which is what the name now says. - **Breaking:** `shiftActions` and `undoRedoActions` no longer receive the `actionElement` bulk control override, despite the plural suffix. Bulk classification now uses core's explicit `controlKind` map instead of matching on key suffixes, so a control named `somethingSelector` can no longer silently inherit `valueSelector`. @@ -28,6 +44,10 @@ Query state is now owned entirely by Svelte runes. `QueryManager` is gone from t - `QueryBuilder` publishes context as `setQueryBuilderContext(() => state.context)` rather than an `Object.defineProperty` reflection loop, so the key set is no longer snapshotted at initialization. `getQueryBuilderContext` returns a getter. - Minimum `@react-querybuilder/core` is now 8.23.0, for the query-tool `freeze` opt-out (deep-freezing a Svelte `$state` proxy throws), `shouldCoalesce`, `controlKeys`/`controlKind`, and `DefaultFieldProp`/`DefaultOperatorProp`. +### Fixed + +- Mounting a query with rules whose `value` no longer matches their `operator` — the ones for which core's `getValueEditorReset` returns `reset: true` — is roughly 40x faster. Each such rule commits a query change during mount, and every commit was re-dirtying every prop of every control in the tree, so the cost grew quadratically in the number of reset-eligible rules (~1s for a two-rule case in an eight-rule tree). Control prop bags are now getter-backed objects built once, rather than `$derived` object literals rebuilt per commit: `Control` forwards them through `{...props}`, and Svelte's `spread_props` resolves one key at a time, so each of a control's props subscribes to only its own sources instead of to the union of all of them. Interactive editing was never affected. + ## [0.1.1] - 2026-08-05 ### Fixed diff --git a/docs/customization.md b/docs/customization.md index 00d1ad4..84fd9ef 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -3,7 +3,7 @@ Every part of the rendered tree can be replaced. There are three levels, in order of increasing reach: 1. **Translations** — change the text (or markup) of a label or tooltip. -2. **Snippets and `controlElements`** — replace an individual control. +2. **Snippets and `controls`** — replace an individual control. 3. **Context** — apply either of the above to every query builder in a subtree. Before replacing a component, check whether [styling](./styling.md) gets you there. @@ -36,27 +36,29 @@ Titles are plain strings — they end up in a `title` attribute, which cannot ho ## Replacing a control -Each control has two interchangeable customization points: a snippet prop and a `controlElements` entry. +Every control has one name — `valueEditor`, `removeRuleAction`, `ruleGroup`, and so on — and two ways to supply a replacement: a snippet on the top-level prop of that name, or a component in the `controls` object. + +The split is not arbitrary. Snippets and components are both plain functions at runtime with no reliable way to tell them apart, so each channel is typed for exactly one kind. Snippets get the top-level prop because a `{#snippet}` declared inside a component's tags only becomes a prop when the name is top-level — it cannot populate a nested object. ### Snippet props -For every key `x` of `controlElements` there is an `xSnippet` prop. The snippet takes one argument: the props object the default component would have received. +The snippet takes one argument: the props object the default component would have received. ```svelte -{#snippet valueEditorSnippet(props)} - props.handleOnChange(e.currentTarget.value)} /> -{/snippet} - - + + {#snippet valueEditor(props)} + props.handleOnChange(e.currentTarget.value)} /> + {/snippet} + ``` Snippets are the better fit when the replacement is small, needs values from the surrounding scope, or is only used once. -### `controlElements` +### The `controls` prop Pass a Svelte component instead. Better fit when the replacement is reusable or needs its own state: @@ -65,33 +67,43 @@ Pass a Svelte component instead. Better fit when the replacement is reusable or import MyValueEditor from './MyValueEditor.svelte'; - + ``` -Passing `null` renders nothing: +`null` renders nothing: ```svelte - + +``` + +A snippet can go in `controls` too, wrapped in `{ snippet }`, for configuration assembled programmatically: + +```svelte + ``` ### Bulk overrides -`actionElement`/`actionElementSnippet` replaces every button-type control at once (`addRuleAction`, `removeGroupAction`, `shiftActions`, …), and `valueSelector`/`valueSelectorSnippet` replaces every ``-type control (`fieldSelector`, `operatorSelector`, `combinatorSelector`, `valueSourceSelector`). Both work as a snippet prop or a `controls` entry. Neither applies to `valueEditor`, `rule`, `ruleGroup`, `inlineCombinator`, `notToggle`, or `matchModeEditor`. + +Which controls are "actions" and which are "selectors" comes from core's `controlKind` map, not from the shape of the name — `shiftActions` and `undoRedoActions` are composites and are not bulk-action targets despite the plural suffix. ## Resolution order Each control key is resolved independently. Levels are tried in order — props, then inherited context, then the package defaults — and within a level: -1. the keyed snippet (`valueEditorSnippet`) -2. the keyed component (`controlElements.valueEditor`), where `null` means "render nothing" and stops the search -3. the bulk snippet (`valueSelectorSnippet`) -4. the bulk component (`controlElements.valueSelector`) +1. the keyed snippet (the `valueEditor` prop) +2. the keyed entry (`controls.valueEditor`), where `null` means "render nothing" and stops the search +3. the bulk snippet (the `valueSelector` prop) +4. the bulk entry (`controls.valueSelector`) So a snippet passed to `QueryBuilder` beats a component passed to `QueryBuilder`, which beats anything inherited from context, which beats the default. ## Applying customization to a subtree -Context carries configuration — `controlElements`, `controlClassnames`, `translations`, and the boolean flags — down to every query builder below it, including the subquery builders that match modes create. +Context carries configuration — `controls`, `controlClassnames`, `translations`, and the boolean flags — down to every query builder below it, including the subquery builders that match modes create. + +A query builder publishes its _resolved_ controls to its descendants, so a nested builder inherits whatever the outer one ended up with, and still overrides it per key with its own props. ```svelte + + props.handleOnChange(e.currentTarget.value)} /> diff --git a/packages/svelte-querybuilder/src/lib/components/InlineCombinator.svelte b/packages/svelte-querybuilder/src/lib/components/InlineCombinator.svelte index 6ad4031..ebf6520 100644 --- a/packages/svelte-querybuilder/src/lib/components/InlineCombinator.svelte +++ b/packages/svelte-querybuilder/src/lib/components/InlineCombinator.svelte @@ -8,12 +8,11 @@ -->

- +
diff --git a/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte b/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte index a660f8f..0ce362e 100644 --- a/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte +++ b/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte @@ -8,6 +8,7 @@ - + {#if requiresThreshold(props.match.mode)} - + {/if} diff --git a/packages/svelte-querybuilder/src/lib/components/QueryBuilder.svelte b/packages/svelte-querybuilder/src/lib/components/QueryBuilder.svelte index 46c61b3..29c32e7 100644 --- a/packages/svelte-querybuilder/src/lib/components/QueryBuilder.svelte +++ b/packages/svelte-querybuilder/src/lib/components/QueryBuilder.svelte @@ -22,6 +22,7 @@ RuleGroupTypeAny, } from '@react-querybuilder/core'; import { rootPath } from '@react-querybuilder/core'; + import Control from '../internal/Control.svelte'; import { setQueryBuilderContext } from '../reactive/context.svelte.js'; import { createQueryBuilderState } from '../reactive/createQueryBuilderState.svelte.js'; import type { QueryBuilderProps } from '../types/props.js'; @@ -42,8 +43,6 @@ // once, at initialization, so what goes in is a getter rather than the value: descendants read // through to the current value instead of capturing the first one. setQueryBuilderContext(() => state.context); - - const RuleGroupControlElement = $derived(state.schema.controls.ruleGroup);
- +
diff --git a/packages/svelte-querybuilder/src/lib/components/QueryBuilder.test.ts b/packages/svelte-querybuilder/src/lib/components/QueryBuilder.test.ts index 8205be9..2c80570 100644 --- a/packages/svelte-querybuilder/src/lib/components/QueryBuilder.test.ts +++ b/packages/svelte-querybuilder/src/lib/components/QueryBuilder.test.ts @@ -317,7 +317,7 @@ describe('QueryBuilder', () => { it('accepts a replacement control element', () => { render(QueryBuilder, { - props: { fields, defaultQuery: flatQuery, controlElements: { removeRuleAction: null } }, + props: { fields, defaultQuery: flatQuery, controls: { removeRuleAction: null } }, }); expect(screen.queryByTestId(TestID.removeRule)).toBeNull(); diff --git a/packages/svelte-querybuilder/src/lib/components/RuleComponents.svelte b/packages/svelte-querybuilder/src/lib/components/RuleComponents.svelte index b08d8d2..313fbc7 100644 --- a/packages/svelte-querybuilder/src/lib/components/RuleComponents.svelte +++ b/packages/svelte-querybuilder/src/lib/components/RuleComponents.svelte @@ -8,6 +8,8 @@ --> {#if schema.showShiftActions} - + {/if} {#if parts.showFieldSelector} - + {/if} {#if schema.autoSelectField || rule.field !== translations.fields.placeholderName} {#if subQueryParts} - + {:else} - + {#if parts.showValueControls} {#if parts.showValueSourceSelector} - + {/if} - + {/if} {/if} {/if} @@ -161,44 +358,15 @@ {/if} {#if schema.showCloneButtons} - + {/if} {#if schema.showLockButtons} - + {/if} {#if schema.showMuteButtons} - + {/if} - + {#if subQueryParts && subQueryProps}
diff --git a/packages/svelte-querybuilder/src/lib/components/RuleGroupBody.svelte b/packages/svelte-querybuilder/src/lib/components/RuleGroupBody.svelte index 38d50b5..4f50663 100644 --- a/packages/svelte-querybuilder/src/lib/components/RuleGroupBody.svelte +++ b/packages/svelte-querybuilder/src/lib/components/RuleGroupBody.svelte @@ -8,6 +8,8 @@ --> {#each ruleGroup.rules as r, idx (typeof r === 'string' ? [...parts.pathsMemo[idx].path, r].join('-') : r.id)} @@ -33,65 +69,119 @@ {@const shiftUpDisabled = path.length === 0 && idx === 0} {@const shiftDownDisabled = path.length === 0 && idx === ruleGroup.rules.length - 1} {#if idx > 0 && !schema.independentCombinators && schema.showCombinatorsBetweenRules} - + {/if} {#if typeof r === 'string'} - parts.onIndependentCombinatorChange(val, idx)} - rules={ruleGroup.rules} - level={path.length} - context={props.context} - validation={parts.validationResult} - component={CombinatorSelectorControlElement} - path={thisPath} - disabled={thisPathDisabled} - {schema} - {ruleGroup} /> + parts.onIndependentCombinatorChange(val, idx), + get path() { + return thisPath; + }, + get disabled() { + return thisPathDisabled; + }, + })} /> {:else if isRuleGroup(r)} - + {:else} - + {/if} {/each} diff --git a/packages/svelte-querybuilder/src/lib/components/RuleGroupHeader.svelte b/packages/svelte-querybuilder/src/lib/components/RuleGroupHeader.svelte index 0fd39d6..5be6c34 100644 --- a/packages/svelte-querybuilder/src/lib/components/RuleGroupHeader.svelte +++ b/packages/svelte-querybuilder/src/lib/components/RuleGroupHeader.svelte @@ -7,6 +7,8 @@ --> {#if schema.showShiftActions && path.length > 0} - + {/if} {#if !schema.showCombinatorsBetweenRules && !schema.independentCombinators} - + {/if} {#if schema.showNotToggle} - + {/if} - + {#if schema.maxLevels > path.length} - + {/if} {#if schema.showCloneButtons && path.length > 0} - + {/if} {#if schema.showLockButtons} - + {/if} {#if schema.showMuteButtons} - + {/if} {#if schema.showUndoRedo && path.length === 0} - + {/if} {#if path.length > 0} - + {/if} diff --git a/packages/svelte-querybuilder/src/lib/components/SnippetHarness.test.svelte b/packages/svelte-querybuilder/src/lib/components/SnippetHarness.test.svelte new file mode 100644 index 0000000..b143d8f --- /dev/null +++ b/packages/svelte-querybuilder/src/lib/components/SnippetHarness.test.svelte @@ -0,0 +1,30 @@ + + + + + {#snippet valueEditor(props: ValueEditorProps)} + props.handleOnChange(e.currentTarget.value)} /> + {/snippet} + {#snippet removeRuleAction(props: ActionProps)} + + {/snippet} + diff --git a/packages/svelte-querybuilder/src/lib/components/UndoRedoActions.svelte b/packages/svelte-querybuilder/src/lib/components/UndoRedoActions.svelte index 2017d2b..8a30bea 100644 --- a/packages/svelte-querybuilder/src/lib/components/UndoRedoActions.svelte +++ b/packages/svelte-querybuilder/src/lib/components/UndoRedoActions.svelte @@ -11,12 +11,13 @@ -->
- history.undo()} - disabled={props.disabled || !history.canUndo} /> - history.redo()} - disabled={props.disabled || !history.canRedo} /> + history.undo(), + disabled: props.disabled || !history.canUndo, + }} /> + history.redo(), + disabled: props.disabled || !history.canRedo, + }} />
diff --git a/packages/svelte-querybuilder/src/lib/components/ValueEditor.svelte b/packages/svelte-querybuilder/src/lib/components/ValueEditor.svelte index 644beed..38930c8 100644 --- a/packages/svelte-querybuilder/src/lib/components/ValueEditor.svelte +++ b/packages/svelte-querybuilder/src/lib/components/ValueEditor.svelte @@ -17,6 +17,7 @@ parseNumber, toArray, } from '@react-querybuilder/core'; + import Control from '../internal/Control.svelte'; import Label from '../internal/Label.svelte'; import { createValueEditorReset } from '../reactive/valueEditorEffect.svelte.js'; import type { ValueEditorProps, ValueSelectorProps } from '../types/props.js'; @@ -30,9 +31,7 @@ const values = $derived(props.values ?? []); const placeholderText = $derived(props.fieldData?.placeholder ?? ''); - const SelectorComponent = $derived( - props.selectorComponent ?? props.schema.controls.valueSelector - ); + const valueSelector = $derived(props.selectorComponent ?? props.schema.controls.valueSelector); createValueEditorReset(() => ({ operator: props.operator, @@ -113,30 +112,36 @@ disabled={props.disabled} oninput={e => multiValueHandler(e.currentTarget.value, i)} /> {:else} - multiValueHandler(v, i)} - disabled={props.disabled} - value={valueAsArray[i] ?? getFirstOption(values)} - options={values} - listsAsArrays={props.listsAsArrays} /> + multiValueHandler(v, i), + disabled: props.disabled, + value: valueAsArray[i] ?? getFirstOption(values), + options: values, + listsAsArrays: props.listsAsArrays, + }} /> {/if} {/each} {:else if type === 'select' || type === 'multiselect'} - + {:else if type === 'textarea'}