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
1 change: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: [jakeboone02]
43 changes: 43 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Publish to npm
on:
release:
types: [published]
workflow_dispatch:

jobs:
publish:
name: Build and publish
runs-on: ubuntu-latest

permissions:
contents: read
id-token: write

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: 0
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
- name: Verify ref is a release tag matching package.json version
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
GITHUB_REF_TYPE: ${{ github.ref_type }}
run: |
if [ "$GITHUB_REF_TYPE" != "tag" ]; then
echo "Refusing to publish from $GITHUB_REF_TYPE '$GITHUB_REF_NAME'; publish only from a release tag." >&2
exit 1
fi
version=$(bun -e "console.log(require('./packages/vue-querybuilder/package.json').version)")
if [ "$GITHUB_REF_NAME" != "v$version" ] && [ "$GITHUB_REF_NAME" != "$version" ]; then
echo "Tag '$GITHUB_REF_NAME' does not match package.json version '$version'." >&2
exit 1
fi
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 24
registry-url: 'https://registry.npmjs.org'
- run: bun install --frozen-lockfile
- run: bun run build
- run: npm publish
working-directory: packages/vue-querybuilder
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
subcomponent still receives the same props. _Props for parity, inject for ergonomics._ Each
returns `undefined` when there is no provider, and is safe to call outside a component
instance.
- **`ControlProps<K>`** (and its key union `ControlPropsKey`), mapping a control key to the props
that control receives — `defineProps<ControlProps<'valueEditor'>>()`, or a `Pick` of it when a
replacement declares only a subset. Type-only and additive; `K` is derived from core's
`ControlKey`, so the alias cannot drift from the canonical key list.
- **Ungroup**, ported from core 8.24.0. Set `showUngroupButtons` to render an "Ungroup" button
(`⊟`) in the header of every group except the root, which replaces that group with its own
rules in the parent. Adds the `ungroupAction` control key (defaulting to `ActionElement`, and a
target of the `actionElement` bulk override and the `#actionElement` slot like every other
`controlKind: 'action'` key), the `onUngroup` callback prop — return `false` to cancel or a
replacement query to substitute one, matching `onMoveRule`/`onGroupRule` — and `ungroup` on
`UseRuleGroupReturn`. Named for core's `ruleGroupHeaderElements` key, not for the
`QueryActions.ungroupRuleGroup` method it calls.
- **Conformance fixtures now track upstream `v8.24.1`** (from `v8.23.0`). The `allControls`
scenario gains `showUngroupButtons`, which is what proves the new button's placement, class,
and label byte-for-byte against React's output.

### Removed

Expand Down
708 changes: 345 additions & 363 deletions bun.lock

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,28 @@ stray attributes.
Keep `data-testid`, `class`, and `title` if you want the standard stylesheets — and any tests
written against the standard DOM — to keep working.

#### Declaring props by control key

The named props types (`ValueEditorProps`, `ActionProps`, …) are the direct route. When you would
rather name the _control key_ — the key you pass to `controlElements` or use as a slot name —
`ControlProps<K>` maps a key to the props that key receives, so a replacement cannot drift from
what the rendering parent actually passes:

```ts
import type { ControlProps } from '@react-querybuilder/vue';

// Equivalent to `ValueEditorProps`.
defineProps<ControlProps<'valueEditor'>>();

// Or just the props you use. Set `inheritAttrs: false` when declaring a subset.
defineProps<Pick<ControlProps<'valueEditor'>, 'value' | 'handleOnChange'>>();
```

`K` is constrained to the control keys this port renders, so a typo or a drag-and-drop key
(`dragHandle`, a non-goal here) is a compile error. The field and option-name type parameters
default to `FullField`/`string` and can be narrowed:
`ControlProps<'valueEditor', MyField, MyOperatorName>`.

### Props for parity, inject for ergonomics

The prop bag is the contract with React Query Builder, and it does not change: a component ported
Expand Down Expand Up @@ -282,6 +304,56 @@ const manager = new QueryManager({ combinator: 'and', rules: [] }, { history: tr
</template>
```

### Holding the manager in a store

A `QueryManager` can live in a Pinia store — or any `reactive()` container — and be shared by
unrelated components. This works as of `@react-querybuilder/core` 8.23.0, which moved the
manager's state into a non-enumerable, symbol-keyed own property flagged `__v_skip`: it reads
correctly through a reactive proxy, and `reactive()` will not deep-proxy its internals. On older
cores the same code threw `Cannot read private member #past`. See
[§3 of the differences doc](./differences-from-react-querybuilder.md#3-state-management) for the
proxy-safety note in full.

```ts
// stores/query.ts
import { QueryManager } from '@react-querybuilder/vue';
import { defineStore } from 'pinia';
import { shallowRef } from 'vue';

export const useQueryStore = defineStore('query', () => {
// The manager is stable; do not wrap it in `ref`. `shallowRef` mirrors the current query so
// components re-render on commit — a deep `ref` would be rejected by the manager's
// deep-freeze.
const manager = new QueryManager({ combinator: 'and', rules: [] }, { history: true });
const query = shallowRef(manager.getQuery());
manager.subscribe(() => {
query.value = manager.getQuery();
});
return { manager, query };
});
```

```vue
<script setup lang="ts">
import { QueryBuilder } from '@react-querybuilder/vue';
import { useQueryStore } from './stores/query';

const store = useQueryStore();
</script>

<template>
<button type="button" @click="store.manager.undo()">Undo</button>
<QueryBuilder :fields="fields" :manager="store.manager" />
<pre>{{ store.query }}</pre>
</template>
```

Pass only `manager` — not `query`/`v-model:query` alongside it. The manager already owns the
query; a `query` prop would push a second source of truth into it.

Pinia is not a dependency of this package. Nothing above is Pinia-specific beyond
`defineStore` — the same shape works with a plain module-scoped composable.

## Classnames

`controlClassnames` appends to the standard classes rather than replacing them, so
Expand Down
4 changes: 3 additions & 1 deletion docs/differences-from-react-querybuilder.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ it happens by accident more often than by choice. As of `@react-querybuilder/cor
manager's state lives in a non-enumerable, symbol-keyed own property, which reads correctly
through a `Proxy`, and that property is flagged so `reactive()` will not deep-proxy the internals
either. No `toRaw()` is required. (Before 8.23.0 the state was in `#private` fields and every
call through a proxy threw `Cannot read private member #past`.)
call through a proxy threw `Cannot read private member #past`.) That is what makes a manager held
in a Pinia store work; see
[Holding the manager in a store](./customization.md#holding-the-manager-in-a-store).

## 4. Query binding

Expand Down
8 changes: 4 additions & 4 deletions examples/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
"check": "vue-tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"vue": "^3.5.41"
"vue": "^3.5.43"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.8",
"@vitejs/plugin-vue": "^6.0.9",
"typescript": "^5.9.3",
"vite": "^8.2.0",
"vue-tsc": "^3.3.9"
"vite": "^8.3.0",
"vue-tsc": "^3.3.11"
}
}
1 change: 1 addition & 0 deletions examples/demo/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const flags = ref({
showNotToggle: true,
showShiftActions: true,
showCloneButtons: true,
showUngroupButtons: true,
showLockButtons: true,
showMuteButtons: true,
showUndoRedo: true,
Expand Down
4 changes: 2 additions & 2 deletions examples/nuxt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
"dependencies": {
"@react-querybuilder/vue": "workspace:*",
"nuxt": "^4.2.0",
"vue": "^3.5.41"
"vue": "^3.5.43"
},
"devDependencies": {
"typescript": "^5.9.3",
"vue-tsc": "^3.3.9"
"vue-tsc": "^3.3.11"
}
}
28 changes: 14 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,23 @@
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/user-event": "^14.6.4",
"@testing-library/user-event": "^14.6.7",
"@testing-library/vue": "^8.1.0",
"@types/bun": "^1.3.14",
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^6.0.8",
"@vitest/coverage-v8": "^4.1.10",
"@vue/test-utils": "^2.4.11",
"jsdom": "^30.0.1",
"oxfmt": "^0.63.0",
"oxlint": "^1.78.0",
"sass": "^1.102.0",
"@types/bun": "^1.4.2",
"@types/node": "^26.6.2",
"@vitejs/plugin-vue": "^6.0.9",
"@vitest/coverage-v8": "^5.0.1",
"@vue/test-utils": "^2.5.1",
"jsdom": "^30.1.0",
"oxfmt": "^0.69.0",
"oxlint": "^1.84.0",
"sass": "^1.104.1",
"typescript": "^5.9.3",
"vite": "^8.2.0",
"vitest": "^4.1.10",
"vite": "^8.3.0",
"vitest": "^5.0.1",
"vitest-axe": "^0.1.0",
"vue": "^3.5.41",
"vue-tsc": "^3.3.9"
"vue": "^3.5.43",
"vue-tsc": "^3.3.11"
},
"overrides": {
"@testing-library/dom": "^10.4.1"
Expand Down
6 changes: 3 additions & 3 deletions packages/vue-querybuilder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,13 @@
"vue": "^3.5"
},
"dependencies": {
"@react-querybuilder/core": "^8.23.0"
"@react-querybuilder/core": "^8.24.1"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"typescript": "^5.9.3",
"vue": "^3.5.41",
"vue-tsc": "^3.3.9"
"vue": "^3.5.43",
"vue-tsc": "^3.3.11"
},
"publishConfig": {
"access": "public"
Expand Down
2 changes: 1 addition & 1 deletion packages/vue-querybuilder/scripts/fetch-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import * as path from 'node:path';
* The upstream release whose fixtures this port is verified against. Bump deliberately, and
* expect a diff in the conformance tests when you do.
*/
export const CONFORMANCE_TAG = 'v8.23.0';
export const CONFORMANCE_TAG = 'v8.24.1';

/**
* The fixture *shape* version. Not the tag: upstream may cut a dozen releases without changing
Expand Down
8 changes: 7 additions & 1 deletion packages/vue-querybuilder/src/components/QueryBuilder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const props = withDefaults(defineProps<QueryBuilderPropsBase<RG, RuleTypeOf<RG>,
showCloneButtons: undefined,
showLockButtons: undefined,
showMuteButtons: undefined,
showUngroupButtons: undefined,
resetOnFieldChange: undefined,
resetOnOperatorChange: undefined,
autoSelectField: undefined,
Expand Down Expand Up @@ -90,8 +91,13 @@ const slots = useSlots();
// Slots are folded into the props object rather than passed separately, so that they merge
// through exactly the same path as `controlElements` and are inherited through `provide`. An
// explicitly passed `slots` prop wins over a template slot of the same name.
//
// The cast is generic variance, not a slot-typing gap: `useQueryBuilder` is invoked at
// `RuleGroupTypeAny`/`FullCombinator`, and `QueryManager<RG, F, O, C>` is invariant in `RG`
// (`getQuery(): RG`), so this component's own `RG`/`C` parameters do not flow into it. Dropping
// the cast fails on `manager`; the slot merge itself typechecks.
const getProps = (): QueryBuilderProps<RuleGroupTypeAny, F, O, FullCombinator> =>
({ ...props, slots: { ...slots, ...props.slots } }) as unknown as QueryBuilderProps<
({ ...props, slots: { ...slots, ...props.slots } }) as QueryBuilderProps<
RuleGroupTypeAny,
F,
O,
Expand Down
36 changes: 36 additions & 0 deletions packages/vue-querybuilder/src/components/a11y.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ describe('accessibility', () => {
defaultQuery: queries.icNested,
showNotToggle: true,
showCloneButtons: true,
showUngroupButtons: true,
showLockButtons: true,
showShiftActions: true,
showMuteButtons: true,
Expand Down Expand Up @@ -125,6 +126,41 @@ describe('keyboard navigation', () => {
expect(rule.contains(document.activeElement)).toBe(false);
});

it('reaches the ungroup button in a nested group header, after clone and before lock', async () => {
render(QueryBuilder, {
props: {
fields,
defaultQuery: queries.nested,
showCloneButtons: true,
showUngroupButtons: true,
showLockButtons: true,
} as never,
});

const header = screen
.getAllByTestId(TestID.ruleGroup)[1]
.querySelector<HTMLElement>('.ruleGroup-header')!;
const order = [...header.querySelectorAll<HTMLElement>('button, select, input')].map(el =>
el.closest('[data-testid]')!.getAttribute('data-testid')!
);
expect(order).toEqual([
TestID.combinators,
TestID.addRule,
TestID.addGroup,
TestID.cloneGroup,
TestID.ungroup,
TestID.lockGroup,
TestID.removeGroup,
]);

// Four groups: the root plus three nested. Only the nested ones get an ungroup button.
expect(screen.getAllByTestId(TestID.ruleGroup)).toHaveLength(4);
screen.getAllByTestId(TestID.ungroup)[0].focus();
await userEvent.keyboard('{Enter}');

expect(screen.getAllByTestId(TestID.ruleGroup)).toHaveLength(3);
});

it('activates a button control with the keyboard', async () => {
render(QueryBuilder, { props: { fields, defaultQuery: queries.singleRule } as never });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ describe('control props', () => {
showShiftActions: true,
showNotToggle: true,
showUndoRedo: true,
showUngroupButtons: true,
};

// Every rendering path, since a control only reports what it is actually handed: the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const defaultControlElements = {
},
shiftActions: ShiftActions,
undoRedoActions: UndoRedoActions,
ungroupAction: ActionElement,
valueEditor: ValueEditor,
valueSelector: ValueSelector,
valueSourceSelector: ValueSelector,
Expand Down
Loading
Loading