Repository navigation
Conversation
d7cf817 to
b6e38a2
Compare
e3126e8 to
f281abc
Compare
e251deb to
ca117d9
Compare
f281abc to
ed6c215
Compare
ca117d9 to
2a2a4ee
Compare
7095a5c to
08c22d9
Compare
10b1623 to
273cb7d
Compare
a5544e9 to
dd76b31
Compare
Design docs for the headless widget concept (generic) and the combobox contract with its safety-net rules and the 8-step migration plan.
…t action mode Adds mobx 6.12.3, @mendix/widget-plugin-mobx-kit and brandi for the headless combobox plan: the selector store becomes observable (step 1) and the core is assembled from a brandi container like the data grid and gallery widgets (step 2). The package now runs Jest through its own jest.config.js so a setup file can enable enforceActions: always and turn the MobX strict-mode warning into a test failure.
Every selector, options provider, captions provider and the lazy-load provider now declares its state with makeObservable. Mendix values enter as observable.ref only, props updates and setters are actions, derived getters are computed. The Selector interface and the re-render trigger in useGetSelector are unchanged so the characterisation tests pass as they are. StaticOptionsProvider loses three field redeclarations that would have shadowed the base class observables.
…hook useGetSelector no longer registers onAfterSearchTermChange to call setState. It subscribes to the observable search term through useObservableSnapshot, a useSyncExternalStore wrapper around a MobX reaction. The hook is the snapshot hook from the headless design docs and will read the ViewModel in step 2.
Adds the combobox-mobx-store change (proposal, design, tasks) and a status line under step 1 of the headless combobox migration plan.
Migration step 2 of docs/design/headless-combobox.md. The core derives a plain ViewModel from the selector store as cached slices, so a View can memoise on them, and the two selection views render from that snapshot only. The core is assembled by a brandi container like the data grid and gallery widgets: a frozen config for the static Studio Pro properties, a props gate for the Mendix values, the selector family bound per instance, and a setup host for the debounced filter-input action. getSelector and useGetSelector are replaced by the container and useComboboxCore; the editor preview uses the same container over the preview selectors.
Migration step 2, third commit. onAfterSearchTermChange leaves the OptionsProvider interface, the base provider and the two preview providers. The view model subscription covers the search term, so the side channel has no caller. Its three characterisation tests go with it; the remaining defaults test keeps its file.
Adds openspec/changes/combobox-viewmodel with the container, config and gate decisions, ticks task 3.2 of combobox-mobx-store, and records the step 2 status and the composition-root decision in the design docs.
…he core Migration step 3, first half. The core owns isOpen and highlightedIndex and exposes the search term as inputValue; options carry a highlighted flag; open/close/toggle/highlight, enter/leave and a reset reason for setInputValue join the commands. The enter and leave actions arrive through the gate, so the core runs them itself and leave closes the menu. The lazy first page is requested by open(), once per datasource filter and read-only state, through a setup reaction. The container creates the core upfront so that reaction is registered before the setup host runs.
…he views Migration step 3, second half. useComboboxBehavior runs downshift in fully controlled mode against the ViewModel and returns prop getters for the root, input, toggle, menu, options, chips, clear and select-all parts; useComboboxPopover wraps the floating menu. The two downshift hooks, useActionEvents and useLazyLoading are gone, the selection views and menus take the ViewModel only, and the wrapper components take plain prop objects instead of downshift getters. The enter and leave callbacks leave the Selector interface. Every item of the "behaviour that must survive" list is covered by a component spec; the DOM is unchanged.
Adds openspec/changes/combobox-behavior with the controlled-downshift, input-value, enter/leave and lazy-first-page decisions, ticks task 3.2 of combobox-viewmodel, and records the step 3 status in the design doc. The selector seam rewrite is split off as the next change.
The selectors and their providers become computed views over the props gate and the frozen config instead of copying props in updateProps. The datasource limit, filter, selection sync and keep-selection writes move into setup reactions. The container pushes props through the gate only. Selector specs resolve their selector from the container with the setup host running. Also binds the Core commands after makeObservable: the first Core instance in a process had unwrapped actions, which failed the component specs when run in isolation.
Proposal, design and tasks for combobox-selector-gate; the step 3 change ticks its follow-up; the design docs record the status and the decision; the changelog gets the static-selection fix.
…up in the view The Atlas View reads its class names from one map with the released names as values, and state is a data attribute on the element next to the modifier class it replaces. The Behavior getters carry the state of their element. Option content is the configured custom content only; the View places it per the custom-content hint. The chip remove control gets its own class, and aria-selected is always present. The five DOM snapshots are updated on purpose; the diff against step 3 is additive. The modifier classes are deprecated and stay for one release, so app CSS keeps matching.
Adds openspec/changes/combobox-classes, the step 4 status and the implemented classes and data attributes table in the combobox design doc, a decision row in the generic doc, and ticks the step 4 item of combobox-behavior.
Local E2E against the test project after the app restart was green with the visual snapshots unchanged. Ticks task 4.5 of the classes change; CI E2E (4.6) still waits for the rebase on main.
…and parts props ComboboxView is one component for both modes, assembled from eleven parts, each something a View author would replace as a whole. Its classes prop replaces the Atlas class of the given keys and its parts prop the given parts; the parts and the components inside them read the resolved class map through a context hook. The two mode views, their menus and the three wrappers are gone. The DOM is unchanged: the five snapshots pass as they are.
openspec/changes/combobox-parts, the parts and view contract sections of the design doc, the decision row in the generic doc.
Local E2E against the test project after the app restart was green with the visual snapshots unchanged. Ticks task 4.5 of the parts change; CI E2E (4.6) still waits for the rebase on main.
The live-region text was assembled inside the Behavior's downshift callback, so a View without the Behavior had nothing to announce. The Core now derives `a11y.statusMessage` from the selection, the open flag and the option count, and the Behavior hands that string to downshift. Three a11y-only differences from the former callback, all deliberate: single mode no longer announces the hardcoded "No options selected." while nothing is selected; multi mode announces the option count regardless of the highlight; a closed multi-select with no options announces its selection instead of the no-options text.
The menu of a multi-select rendered without `aria-multiselectable`, which the ARIA listbox pattern requires for a list with more than one selectable option, and which the Behavior contract in the design doc lists among the menu props. Found by the View conformance suite. The three multi-select snapshots gain the attribute, nothing else.
…view through it `src/conformance/describeComboboxConformance.tsx` takes any component that renders the ViewModel and asserts the behaviour the combobox promises, through roles and ARIA only: a combobox input that opens on click and with the arrow keys, a listbox whose options track selection and highlight, selection with Enter and click, filtering and the no-options text, dismissal with Escape and on leaving the widget together with the enter and leave actions, the clear and remove controls, read-only, validation, select-all, multiselectable listbox and the live region. Sixteen cases, mounted over a live Core from the props fixtures. The Atlas View runs it in `ComboboxView.conformance.spec.tsx`. Section 11 of the generic design doc; this is the suite a View author gets.
…nformance suite
`src/sandbox/BareView.tsx` is the throwaway second View of migration step 7: plain markup with
its own keyboard handling and ARIA over the ViewModel commands, no Behavior, no Popover, no
classes. It stands in for a View built on a component library that owns keyboard and ARIA itself,
and it passes the conformance suite unchanged. Nothing in `src/sandbox/` is imported by the
widget, so the bundle is unaffected.
What the exercise showed about the ViewModel: `select` does not close a single-select menu and
`toggleItem` does not reset the filter text, so a View without the Behavior calls `close` and
`setInputValue("", "reset")` itself; the status message had to move into the Core; there is no
text key for the toggle control, so the sandbox hides its toggle from assistive technology.
…as an openspec change `openspec/changes/combobox-second-view`: the suite's cases, who runs it, the decisions (step 7 before step 6, a hand-rolled View instead of a component library, the status message in the Core, the multiselectable fix) and what the second View showed about the ViewModel. Status lines in both design docs, a decision row and the toggle text key as an open question.
c0928be to
a867629
Compare
… the input facet The envelope keeps the five fields every pluggable widget fills: id, status, hints, t and tabIndex. Input widgets add InputViewModel, Studio Pro's own input bundle: readOnly, validation, a11y with label, required and describedBy, and the enter and leave commands. The combobox composes both and keeps its live-region statusMessage in its own a11y type. The combobox view model gains placeholder next to selectedCaption, which now holds the selection's caption only; emptyOption leaves the text map because the core resolves it, including the database display-value rule. className and style leave the envelope: a widget with the Label system property never receives them. Hosting five views in a Studio Pro app over the same view model drove the changes; the design documents record the survey of the widget XMLs, the view contract on the snapshot, the focus boundary as a helper instead of a host element and the decisions. The conformance suite pins the empty option text and the id the label targets.
…t name asks for one Trial of the in-project route ahead of the package move (design step 8). A widget whose Studio Pro name ends in _atlas, _tailwind, _mui, _antd or _ix renders the Combobox export of the matching @combobox-ui package from the app's node_modules, loaded lazily. The packages are external to the widget bundle so that the app bundler resolves them into chunks of their own. Any other name renders the Atlas view as before. The name suffix stands in for a uiVariant property; the fixed import list stands in for the registry, see the open questions in the design.
…kages Two private workspace packages under packages/kits, built with tsc like the shared plugin packages. @mendix/widget-kit holds what every headless widget shares: the view model envelope and the input facet, focusBoundaryProps and useObservableSnapshot. @mendix/combobox-kit holds the core with its container and selectors, the hooks, the Atlas view with its parts and classes (src/atlas), the conformance suite, the preview selectors and the sandbox view, behind four entries: the contract, ./atlas, ./conformance and ./preview. The widget keeps the XML, the editor config and preview, the icons, the stylesheet, the wrapper, the trial registry and its three specs, which run against the package's dist with the floating hook mocked through the package path. The package carries a copy of the generated typings as a .ts file, guarded by a spec that fails when it drifts from the widget's file. The Behavior's root props and the bare view use the shared focus helper. 91 files move with their history; the five snapshots pass unchanged.
openspec/changes/combobox-package-move, the step 6 status and package layout in the combobox design, the two-package layout and the step 6 decision row in the generic design, the package path in the two earlier changes that pointed at it.
| </div> | ||
| ); | ||
| } | ||
| const variant = uiVariantFromName(props.name); |
There was a problem hiding this comment.
🚨 High — widget name now changes behaviour and can break existing apps. uiVariantFromName(props.name) switches the rendered View on any widget name ending in _atlas|_tailwind|_mui|_antd|_ix. An existing app with a combo box named e.g. comboBox_ix or status_atlas will, after upgrading, lazily import("@combobox-ui/ix"). That package is external to the bundle (rollup.config.mjs) and does not exist in normal apps, so the dynamic import rejects. There is no error boundary around the Suspense, so the whole page or subtree crashes. The UnavailablePlaceholder path above does not help here.
Fix: keep this trial out of the shipped widget. Put it on a research-only build or flag, or add an error boundary that falls back to <ComboboxView vm={vm} />. Move it behind a real, opt-in widget property before release. Also, the external regexp in rollup.config.mjs plus the dynamic imports leave unresolvable bare specifiers in the shipped MPK.
This comment has been minimized.
This comment has been minimized.
Remove the name-suffix trial: the view registry with its lazy imports of `@combobox-ui/*`, the module typings and the rollup externals. The test project in CI has no `node_modules`, so mxbuild could not bundle the widget and the e2e job failed before any test ran. The dynamic imports return with the `uiVariant` property in step 8.
AI Code Review
What was reviewed
Skipped (out of scope): CI: pending: Run code quality check, Plan E2E matrix, sigridci · unrelated failure: Questions
Positives
|
Research branch for the headless combobox; see
docs/design/headless-widgets.mdanddocs/design/headless-combobox.md, both added here. Built on the unit-test improvements of #2499, merged intomainon 2026-11-03; this branch is rebased on thatmainand targets it.Migration steps 1 to 5 and 7 of the combobox design doc, as commits (step 7 taken before 6: a second View tests the contract, the package move does not):
docs: add headless widgets design docs— the two design documents.build(combobox-web): add mobx, mobx-kit and brandi, run jest in strict action mode— dependencies, thejest.setup.tsof test(combobox-web): characterisation tests at the selector seam and typed test mocks #2499 gainsenforceActions: "always"and[MobX]warnings turned into errors.refactor(combobox-web): make the selector store observable with mobx— every selector and provider annotated withmakeObservable; thesetState({})trigger kept so this commit is a pure store change.refactor(combobox-web): replace the forced re-render with a snapshot hook—useObservableSnapshot(useSyncExternalStoreover a MobXreaction) subscribed to the search term.docs(combobox-web): record migration step 1 as an openspec change—openspec/changes/combobox-mobx-store.feat(combobox-web): add the combobox core, its container and view model—src/core/: the generic envelope, the combobox ViewModel contract, and the composition root in the style of datagrid-web and gallery-web:comboboxConfig(static properties read once, including the selector family),ComboboxGateProps(the Mendix values the Core derives from),CB_TOKENS,ComboboxContainer+createComboboxContainer,FilterInputEventService(the debounced filter-input action as a setup component, disposed on unmount),ComboboxCore(injected with selector, config, gate and the service; cached slices, bound commands) anduseComboboxCore.SingleSelectionandMultiSelectionread the snapshot only.getSelector,useGetSelectorand the hand-built preview core props are gone; the editor preview usescreatePreviewContainer.refactor(combobox-web): drop the deprecated search-term callback—onAfterSearchTermChangeand its three tests.docs(combobox-web): record migration step 2 as an openspec change—openspec/changes/combobox-viewmodel, status lines and the composition-root decision in the design docs.feat(combobox-web): own the open state, input text and highlight in the core—isOpen,highlightedIndex,inputValue(the search term) andhighlightedper option on the ViewModel;open/close/toggle/highlight/enter/leavecommands; enter and leave actions through the gate; the lazy first page requested byopen()once per datasource filter, through a setup reaction.refactor(combobox-web): extract the behavior and popover hooks from the views—useComboboxBehaviorruns downshift fully controlled against the ViewModel and returns the section 4 prop getters;useComboboxPopoverwraps the floating menu; the views and menus take the ViewModel only; the two downshift hooks,useActionEventsanduseLazyLoadingare gone. Seven Behavior tests cover the "must survive" items the component specs did not. Single mode callsgetDropdownPropsof the always-mounteduseMultipleSelection, which the strict Jest setup of test(combobox-web): characterisation tests at the selector seam and typed test mocks #2499 requires.docs(combobox-web): record migration step 3 as an openspec change—openspec/changes/combobox-behavior, status line in the design doc.refactor(combobox-web): derive the selectors from the props gate— the selectors and their providers are computeds over the props gate and the frozen config, the way the data grid and gallery models derive from theirs; the datasource limit, filter, selected-value reload and selection sync are setup reactions;container.setPropsis the gate alone; the association and database families share oneDatasourceOptionsProviderand oneDatasourceCaptionsProvider; the preview selectors are plain objects. The selector specs resolve their selector from the container with the setup host running. Five deliberate behaviour changes are listed in the change'sdesign.md; the user-visible one (a static combo box follows an external clear of its attribute) has a CHANGELOG line. Also fixes a latent Core bug: the commands were bound beforemakeObservable, so the first Core instance in a process had unwrapped actions andpnpm run test -t "sets option to selected item"failed in isolation. 59 files, +1466 / -2295 insrc/.docs(combobox-web): record the selector gate as an openspec change—openspec/changes/combobox-selector-gate, status and decision lines in the design docs, the changelog line.docs(combobox-web): record the green e2e run for the selector gate— tasks and design notes of the selector gate change.refactor(combobox-web): classes map, data attributes and caption markup in the view—src/components/classes.tsholds every class name of the Atlas View with the released names as values; state is a data attribute on the element (data-open,data-highlighted,data-selected, ...) instead of a modifier class, carried by the Behavior getters where downshift owns the state and set by the View otherwise;Combobox.scsstargets the attributes.ComboboxOptionVM.contentis the configured custom content only and the newOptionContentcomponent places it perhints.customContent; the captions providers loserenderand their React imports;selectedContentisselectedCaption. The chip remove control gets its own class andaria-selectedis always present. This is the one step that changes the DOM on purpose: the five snapshots were updated once and the diff against step 3 is additive, two data attributes next to the classes that were there. The modifier classes stay for one release and are deprecated in the CHANGELOG, so no app CSS breaks.docs(combobox-web): record the classes map as an openspec change—openspec/changes/combobox-classes, the implemented classes and attributes table in the design doc.docs(combobox-web): keep the openspec changes free of personal names— two wording fixes.docs(combobox-web): record the green e2e run for the classes map— tasks and design notes of the classes change.refactor(combobox-web): split the atlas view into parts with classes and parts props—ComboboxViewis one component for both modes, assembled from eleven parts (InputContainer,Placeholder,Chip,Clear,Toggle,Validation,Menu,SelectAll,Option,NoOptions,Loader), each something a View author would replace as a whole;classesreplaces the Atlas class of the given keys,partsthe given parts, and the parts read the resolved map throughuseComboboxClasses. The two mode views, their menus and the three wrappers are gone. The DOM is unchanged: the five snapshots pass as they are.docs(combobox-web): record the parts split as an openspec change—openspec/changes/combobox-parts, sections 7 and 8 of the design doc, the decision row in the generic doc.docs(combobox-web): record the green e2e run for the parts split— tasks and design notes of the parts change.feat(combobox-web): compose the a11y status message in the core—a11y.statusMessageis derived from the selection, the open flag and the option count throught; the Behavior hands it to downshift. Three a11y-only wording differences, listed in the change'sdesign.md.fix(combobox-web): mark the multi-select listbox as multiselectable—aria-multiselectableon the menu props in multi mode, found by the conformance suite; the three multi-select snapshots gain that line.test(combobox-web): add the view conformance suite and run the atlas view through it—src/conformance/describeComboboxConformance.tsx, sixteen role-based cases that take any component rendering the ViewModel, mounted over a live Core;ComboboxView.conformance.spec.tsxruns them for the Atlas View. Section 11 of the generic design.feat(combobox-web): add a bare sandbox view and run it through the conformance suite—src/sandbox/BareView.tsx, the throwaway second View: plain markup with its own keyboard handling and ARIA over the ViewModel commands, no Behavior, no Popover, no classes, not in the bundle. Passes the suite unchanged; what a View without the Behavior does itself is in the change'sdesign.md.docs(combobox-web): record the second view and the conformance suite as an openspec change—openspec/changes/combobox-second-view, status lines, a decision row, the toggle text key as an open question.feat(combobox-web): split the view model into the widget envelope and the input facet— after hosting five Views (plain CSS, Tailwind, Material UI, Ant Design, Siemens iX) in a Studio Pro app over the same ViewModel:WidgetViewModelkeeps the five fields every pluggable widget fills (id,status,hints,t,tabIndex);InputViewModelis Studio Pro's input bundle (readOnly,validation,a11y,enter,leave); the combobox composes both and keepsstatusMessagein its owna11ytype.placeholderis split fromselectedCaption,emptyOptionleaves the text map,classNameandstyleleave the envelope. Section 7 of the generic design records the survey of the 55 widget XMLs behind it; the conformance suite pins the empty option text and the id the label targets.feat(combobox-web): render a view from the app project when the widget name asks for one— the step 8 trial, ahead of step 6: a widget named*_atlas|_tailwind|_mui|_antd|_ixlazily renders theComboboxexport of the matching@combobox-ui/<name>package from the app'snode_modules; the packages are external to the widget bundle so the app bundler makes a chunk per package. The name suffix stands in foruiVariant, the fixed import list for the registry (open question in the design).refactor(combobox-web): move the core and the atlas view into headless packages— step 6. Two private workspace packages underpackages/kits, built withtsclike the shared plugin packages:@mendix/widget-kit(the envelope and input facet,focusBoundaryProps,useObservableSnapshot) and@mendix/combobox-kit(core, selectors, hooks, the Atlas View assrc/atlas, conformance, preview selectors, sandbox) behind four entries: the contract,./atlas,./conformance,./preview. The widget keeps the XML, editor files, icons, stylesheet, wrapper, trial registry and its three specs, which run against the package'sdist. The package carries a copy of the generated typings guarded by an equality spec. 91 files moved with history; the five snapshots pass unchanged; the MPK bundles the package.docs(combobox-web): record the package move as an openspec change—openspec/changes/combobox-package-move, step 6 status, the two-package layout and the decision row.Deferred on purpose and listed in the changes:
a11y.statusMessagestays composed in the Behavior until a View without Behavior needs it (step 7); theclassesandpartsprops on the View are step 5.brandi-reactis not added: views read a plain snapshot and never resolve from the container.Verification at the tip:
pnpm exec tsc --noEmitclean in the widget;pnpm run test23 suites and 280 tests across the three packages (widget-kit 3, combobox-kit 226, combobox-web 51) (the 12 view cases of #2499 included), 5 snapshots updated once in the classes commit (additive diff) and once in the multiselectable fix (one attribute line in the three multi-select snapshots); lint 0 errors;pnpm run buildOK. E2E ran locally against the test project for step 3: 52 passed, 2 skipped, and the 3 failures were regressions of the first cut (single-select leave kept the typed text, multi-select reopened highlighted), fixed and pinned by unit tests before the push; the re-run after an app restart was green. The local E2E run for the selector gate change was green after an app restart. The local E2E run for the classes commit was green with the visual snapshots unchanged. The local E2E run for the parts commit was green after an app restart; it changes no DOM. The second-view commits change one attribute; the local E2E run for them is pending. Rebased onmainafter #2499 merged on 2026-11-03 (strict downshift console check and view cases in the base), so CI runs the unit and E2E suites on this PR from now on.