diff --git a/docs/plans/use-combobox-architecture.md b/docs/plans/use-combobox-architecture.md new file mode 100644 index 00000000..e2d63c35 --- /dev/null +++ b/docs/plans/use-combobox-architecture.md @@ -0,0 +1,447 @@ +# use_combobox Architecture Plan + +## Goal + +Add a reusable `use_combobox()` primitive hook that can power higher-level components such as `Autocomplete`, `Select`, `MultiSelect`, `TagsInput`, and `PillsInput`. + +The hook should follow the spirit of Mantine's `useCombobox`: shared dropdown, focus, highlighted-option, and option-submit behavior lives in one store, while each higher-level component owns its own value and query model. + +## Source Model + +Mantine's `useCombobox()` provides a store with: + +- controlled or uncontrolled dropdown open state +- event-source-aware open, close, and toggle methods +- highlighted option index navigation +- first, active, next, previous, reset, update, and click selected option methods +- list id and target/search focus refs +- deferred focus and selected-index updates + +Mantine components then compose that store differently: + +- `Autocomplete` owns input text and maps option submit to setting the input label. +- Mantine `Select` owns a single selected value and search text. +- `MultiSelect` owns selected value arrays, pills, search text, and removal behavior. +- `TagsInput` owns tag parsing, tag arrays, pills, and search text. + +The key architectural point is that `useCombobox()` is the shared interaction engine, not the owner of every component's value state. + +## Local Constraints + +The local repo already has related primitive infrastructure: + +- `primitives/src/combobox/context.rs` contains `ComboboxContext`. +- `primitives/src/combobox/components/*` contains the current primitive components. +- `primitives/src/selectable.rs` owns generic selectable value behavior. +- `primitives/src/listbox.rs`, `focus.rs`, and `selection.rs` provide list/focus/selection concepts. +- `dioxus-components/src/components/combobox/*` contains styled wrappers. +- The current local multi-value select primitive is named `SelectMulti`; a separate `MultiSelect` component should still be added rather than treating `SelectMulti` as the final public multi-select surface. +- Local `Select`/`SelectMulti` currently use typeahead behavior rather than Mantine-style searchable query text. +- Local `Autocomplete`, `TagsInput`, `PillsInput`, and `MultiSelect` component modules do not exist yet; those names describe the target Mantine-inspired component set. + +The new hook must not create an unrelated parallel system. It should become the backing store for the combobox primitive layer while preserving existing public component behavior where practical. + +## Existing Hooks and Helpers to Reuse + +The implementation should fit the repo's existing "provider hook plus per-element hook" pattern instead of inventing a separate prop-building style. + +Relevant existing pieces: + +- `use_controlled`: existing controlled/uncontrolled state helper. `use_disclosure()` should build on this or replace repeated boolean open-state usage with a public transition-aware abstraction. +- `use_focus_provider`: creates a signal-backed `FocusState` used by roving focus systems. +- `use_focus_entry_disabled`: registers an item index and disabled state with a focus provider. +- `use_focus_control_disabled` / `use_focus_controlled_item_disabled`: return `onmounted` handlers for focusable elements and keep mounted-node focus control outside component bodies. +- `use_deferred_focus`: handles "focus first/last after something opens" behavior. +- `use_listbox_container`: wires listbox id/render state and animated open rendering. +- `use_listbox_option`: registers an option id, index, text value, disabled state, and value metadata. +- `use_selectable_root` / `use_selectable_option`: current higher-level composition of controlled open state, focus state, option registry, and selected values. +- `pointer_select_start`, `pointer_select_commit`, and `pointer_select_cancel`: normalize pointer selection and avoid accidental touch scroll selection. + +Preferred combobox direction: + +- Factor reusable focus/listbox/option-registry behavior out of `SelectableContext` where needed rather than duplicating it inside `ComboboxStore`. +- Keep `SelectableContext` for components that own selected values, but avoid making `use_combobox()` depend on selected-value ownership. +- Follow the local hook shape where root hooks provide clone/copy signal-backed context handles and element hooks return event/mounted handlers to spread into rendered elements. +- If a hook needs to return a bundle for spreading, use a small typed struct with event handlers and computed ids/attributes rather than ad hoc attributes in unrelated component code. + +## Core Boundary + +`use_combobox()` should be value-agnostic. + +It should own: + +- dropdown opened state +- open, close, and toggle behavior +- event source for open/close requests +- highlighted option index +- option registration +- disabled and invisible option skipping +- active-option selection +- selected-option submit request +- target/search focus wiring +- focus target/search methods +- list and active-descendant ids + +It should not own: + +- selected value +- selected value arrays +- tag arrays +- pill rendering +- query filtering in the first implementation +- virtualizer scroll state +- component-specific clear, blur, or create-new-item behavior + +Higher-level components should compose `use_combobox()` and then own their domain state. + +## Public API Shape + +Add a new module: + +```text +primitives/src/combobox/hook.rs +``` + +Export it from: + +```text +primitives/src/combobox/mod.rs +``` + +Initial public types: + +```rust +pub enum ComboboxDropdownEventSource { + Keyboard, + Mouse, + Unknown, +} + +pub struct UseComboboxOptions { + pub opened: Option, + pub default_opened: Option, + pub on_opened_change: Option>, + pub on_dropdown_open: Option>, + pub on_dropdown_close: Option>, + pub loop_navigation: bool, +} + +pub struct ComboboxStore { + // private fields +} + +pub struct ComboboxSubmittedOption { + pub id: String, + pub index: usize, + // Additional metadata can be added here without making the hook own selected values. +} + +pub fn use_combobox(options: UseComboboxOptions) -> ComboboxStore; +``` + +Do not expose the current crate-internal `Controlled` in this public API unless it is intentionally made public and documented. The initial hook can use explicit `opened`, `default_opened`, and callback fields, or it can delegate that open-state trio to a new public `use_disclosure()` hook if that hook is added first. + +Preferred direction: introduce `use_disclosure()` as the reusable public open/closed state primitive, then have `use_combobox()` build on it for dropdown state. `use_disclosure()` should own controlled/uncontrolled boolean state plus transition-aware `open`, `close`, and `toggle` helpers. `use_combobox()` should add combobox-specific event-source callbacks on top. + +`ComboboxStore` should be a clone/copy signal-backed handle that can be moved into Dioxus closures and contexts. It should follow the local shape of `ComboboxContext` and `SelectableContext`: cheap handles containing signals, memos, and callbacks rather than a uniquely borrowed mutable state object. + +## Store Methods + +The store should expose methods equivalent to Mantine's behavior, adapted to Rust naming and local semantics: + +```rust +impl ComboboxStore { + pub fn dropdown_opened(&self) -> bool; + pub fn open_dropdown(&self, source: ComboboxDropdownEventSource); + pub fn close_dropdown(&self, source: ComboboxDropdownEventSource); + pub fn toggle_dropdown(&self, source: ComboboxDropdownEventSource); + + pub fn highlighted_option_index(&self) -> Option; + pub fn select_option(&self, index: usize) -> Option; + pub fn select_first_option(&self) -> Option; + pub fn select_active_option(&self) -> Option; + pub fn select_next_option(&self) -> Option; + pub fn select_previous_option(&self) -> Option; + pub fn reset_selected_option(&self); + pub fn update_selected_option_index(&self, target: ComboboxIndexTarget); + + pub fn submitted_option(&self) -> Option; + + pub fn focus_target(&self); + pub fn focus_search_input(&self); +} +``` + +Use `highlighted_option_index` internally and publicly when possible. Mantine calls this `selectedOptionIndex`, but in this repo "selected" already means selected value, so "highlighted" is less ambiguous. + +The method signatures above are intentionally signal-handle style. Future implementation may adjust exact argument types, but it should not require an exclusive `&mut ComboboxStore` borrow for normal event handlers. + +Mounted-node registration for targets and search inputs should stay behind declarative element hooks such as `use_combobox_target()` and `use_combobox_search()`, whose handles expose `spread()` for the rendered element. Raw attribute helpers such as `use_combobox_target_attributes()` and `use_combobox_search_attributes()` may remain as compatibility wrappers, but `ComboboxStore` should not expose public mount-registration methods. + +Navigation methods should return stable option keys or submitted-option metadata, not selected values. Root/context code should translate the returned option metadata into the component's value behavior. + +## Option Registry + +Mantine uses DOM queries under a list id. Dioxus should prefer an explicit Rust registry. + +Each option should register: + +- stable id +- index/order +- option value or submit payload data +- disabled state +- visible state +- active state +- mounted node, if available + +`ComboboxOption` should register and unregister itself through the store/context lifecycle. + +Submit ownership should stay at the combobox root/context boundary, matching Mantine's `Combobox` `onOptionSubmit` model. Options provide value/id metadata; the store can request submission of the currently highlighted option, but the root-level context decides how to handle that submitted value. Avoid per-option submit handlers as the default model because they make `Autocomplete`, `Select`, `SelectMulti`, creatable tags, and custom option rendering harder to compose. + +The preferred dispatch shape is: + +```text +keyboard/pointer event + -> store selects or looks up highlighted option + -> store returns ComboboxSubmittedOption metadata + -> ComboboxContext/root on_option_submit handles component-specific value changes +``` + +This keeps the store value-agnostic while still giving `Autocomplete`, `Select`, `SelectMulti`, `TagsInput`, and custom combobox users a single root-level submit path. + +Navigation should walk the registry and skip disabled or invisible options. Dynamic lists and filtering should update the registry without leaving stale highlighted indices. + +Existing rendered attributes should be preserved: + +- `data-highlighted` +- `data-disabled` +- `data-selected` +- `aria-selected` +- `aria-disabled` + +Only add Mantine-style `data-combobox-*` attributes if there is a concrete styling, testing, or compatibility reason. Do not replace the existing attributes because styled wrappers may depend on them. + +## Open and Close Semantics + +Open and close callbacks should follow Mantine's transition semantics: + +- `open_dropdown` should call `on_dropdown_open` only when transitioning from closed to open. +- `close_dropdown` should call `on_dropdown_close` only when transitioning from open to closed. +- `on_opened_change` should reflect actual state changes, not repeated requests to set the current state. +- event source should be preserved through `toggle_dropdown`. + +This should be handled inside the store instead of relying directly on the current `use_controlled` helper, because the helper may call callbacks on set requests even when transition-specific callbacks should not fire. + +## Focus Model + +React refs from Mantine map to Dioxus mounted-node registration. + +The store should hold optional mounted data for: + +- target +- search input +- registered options, if option scrolling/focus is later supported + +`focus_target()` and `focus_search_input()` should no-op safely when mounted data is absent, including SSR. If deferred focus is needed to match Mantine behavior, implement it explicitly and clean up timers/tasks on drop. + +The composition model should account for more than the current `ComboboxInput` component. Mantine separates target, events target, dropdown target, search input, dropdown, options, and option components. The Dioxus implementation should support these shapes from the beginning: + +- input-as-target autocomplete +- button or custom element as target +- search input inside dropdown +- pill input where events target and dropdown target are not the same rendered node +- non-input target with keyboard event handling + +The first implementation should include these distinct primitive pieces instead of treating `ComboboxInput` as the only valid target. The store/context should support separate target, events target, dropdown target, and search input wiring from the beginning. + +## Query and Filtering Boundary + +Keep query and filtering in the existing component/context layer for the first implementation. + +Current combobox behavior includes: + +- controlled or uncontrolled query +- `default_query` +- `on_query_change` +- filter function +- empty rendering +- closed input showing selected text while open input shows query + +Moving all of that into `use_combobox()` immediately would make the hook too opinionated for `TagsInput`, `PillsInput`, and other future inputs. A later `use_autocomplete()` or component-specific wrapper can combine `use_combobox()` with query state. + +## Compatibility Wrapper + +The existing `Combobox` component should remain compatible. + +It can keep value ownership through existing selectable primitives while delegating interaction state to `ComboboxStore`: + +- option submit calls existing selected-value machinery +- single-select submit closes the dropdown +- current query/filter behavior remains in the wrapper/context +- existing styled wrappers continue to render the same visible structure + +This preserves the public component API while making the shared interaction logic reusable. + +## Virtualization + +Virtualized combobox support should be designed as an adapter, not baked into the base hook. + +Future API: + +```rust +pub fn use_virtualized_combobox(options: UseVirtualizedComboboxOptions) -> ComboboxStore; +``` + +Virtualized options should provide: + +- total option count +- disabled predicate by index +- option id by index +- active option index +- highlighted option index +- external highlighted-index setter +- scroll-to-index callback +- submit callback by index + +The base hook should not require all options to be mounted. The first implementation should keep the registry abstraction narrow enough that a virtual registry backend can be added without redesigning every store method. + +The lower-level `primitives/src/virtual/*` virtualizer algorithms are the intended reusable layer for combobox virtualization. The existing `virtual_list` component renders generic list/listitem semantics. A virtualized combobox adapter must provide listbox/option semantics instead: + +- stable option ids for `aria-activedescendant` +- `role="listbox"` and `role="option"` where appropriate +- highlighted option state even when the highlighted row is not mounted +- scroll-to-index integration +- disabled option lookup by index + +Do not treat the current `virtual_list` wrapper as automatically accessible for combobox usage without this role/id integration. + +The public virtualizer module is a low-level primitive API, not a complete accessible component. Consumers are responsible for roles, ids, keyboard behavior, focus management, and scroll container wiring. + +## Higher-Level Component Composition + +Expected composition model: + +```text +use_combobox() + owns dropdown, highlighted index, focus, registry, submit dispatch + +Autocomplete + owns String value/search and maps submit to set input label + +Select + local current component owns Option value plus typeahead behavior; a future searchable Select would add query/search state + +SelectMulti + remains the existing select primitive/component path + +MultiSelect + should be added separately; owns Vec, search, max-values, pill removal, and maps submit to toggle/add + +TagsInput + owns Vec, parser, duplicate handling, search, and maps submit/Enter to add tag + +PillsInput + owns pill layout, keyboard removal, and input composition +``` + +## Implementation Waves + +### Wave 1: Store Foundation + +- Add `primitives/src/combobox/hook.rs`. +- Add public event-source/options/store types. +- Implement controlled/uncontrolled open state. +- Implement transition-aware open/close/toggle callbacks. +- Implement highlighted index state. +- Implement normal mounted option registry. +- Add unit tests for store-only behavior. + +### Wave 2: Full Primitive Anatomy Integration + +- Refactor `ComboboxContext` to carry or wrap `ComboboxStore`. +- Refactor internal `use_combobox_root` to initialize the store. +- Add or refactor primitive pieces for `ComboboxTarget`, `ComboboxEventsTarget`, `ComboboxDropdownTarget`, and `ComboboxSearch`. +- Update `ComboboxInput` to compose the appropriate target/events/search behavior instead of being the only target model. +- Add or rename `ComboboxOptions` if needed, while keeping `ComboboxList` as a compatibility alias if the current API already exposes it. +- Update `ComboboxOption` to register with the store. +- Keep root-level option submit handling in `ComboboxContext`; options should provide values, not own submit callbacks by default. +- Preserve existing component props and rendered attributes. + +Wave 2 acceptance criteria: + +- `ComboboxTarget`, `ComboboxEventsTarget`, `ComboboxDropdownTarget`, and `ComboboxSearch` can mount independently. +- events target and dropdown target can be different DOM nodes. +- `ComboboxInput` is composition sugar over the lower-level primitives, not the only target model. +- ARIA ids and `aria-activedescendant` wire correctly through split target/search/list anatomy. +- `ComboboxList` remains compatible or aliases `ComboboxOptions` without breaking existing users. +- existing option attributes and selected/highlighted behavior are preserved. +- new element-level hooks reuse the existing focus/listbox/pointer helper patterns where applicable. + +### Wave 3: Compatibility Tests + +- Add SSR/render tests for: + - `aria-controls` + - `aria-activedescendant` + - `aria-selected` + - `aria-disabled` + - data attributes + - empty state rendering + - existing `Combobox` value behavior +- Add behavior tests for: + - disabled option skipping + - invisible option skipping + - loop and no-loop navigation + - active option selection + - dynamic registry updates + - submit selected option +- Add targeted browser tests for: + - keyboard navigation + - pointer selection + - focus and blur behavior + - `focus_target()` and `focus_search_input()` + - mounted dropdown behavior + +### Wave 4: Higher-Level Components + +After the primitive store is stable: + +- build or refactor `Autocomplete` on top of `use_combobox()` +- refactor `Select` if needed +- keep existing `SelectMulti` +- add a separate `MultiSelect` on top of `use_combobox()` +- add `PillsInput` +- add `TagsInput` + +Each component should own its own value/query model and use the store only for combobox interaction behavior. + +### Wave 5: Virtualized Combobox + +- Add a virtual registry/provider shape. +- Add `use_virtualized_combobox()`. +- Do not integrate combobox virtualization through the current public `VirtualList` component as-is. +- Reuse the public lower-level virtualizer algorithms. +- Add a combobox-specific virtualized listbox wrapper that renders listbox/option roles, stable option ids, active descendant wiring, and scroll-to-index behavior. +- Add examples for large option sets. + +## Validation + +Minimum validation before considering the primitive complete: + +- targeted Rust tests for the new hook and context +- SSR/render tests for ARIA and data attributes +- targeted browser tests for focus, keyboard, pointer, blur, and mounted-node behavior +- existing combobox preview still works +- existing select/multi-select usage still compiles +- no public API break unless intentionally documented + +## Decisions + +- Introduce a public `use_disclosure()` hook for reusable controlled/uncontrolled open state, then use it from `use_combobox()` for dropdown state. `use_combobox()` should still own combobox-specific event-source semantics. +- Support explicit option order, with registration order as a fallback for simple mounted lists. Virtualized comboboxes must use explicit absolute indices. +- Defer `scroll_into_view` until mounted option scrolling and virtualizer scroll-to-index integration are implemented. +- Keep query state out of `use_combobox()`. Add a separate `use_autocomplete()` hook later if query/filter behavior repeats across components. +- Let virtualized support expose the same public combobox behavior surface, but allow a compatible wrapper type such as `VirtualizedComboboxStore` internally if the virtual registry/provider needs extra state. + +## Open Decisions + +None currently. Revisit after implementation discovery reveals new constraints. diff --git a/playwright/combobox.spec.ts b/playwright/combobox.spec.ts index b773c4f0..53076731 100644 --- a/playwright/combobox.spec.ts +++ b/playwright/combobox.spec.ts @@ -3,6 +3,8 @@ import { test, expect, devices, type Page } from "@playwright/test"; const URL = "http://127.0.0.1:8080/component/?name=combobox&"; const variantUrl = (variant: string) => `http://127.0.0.1:8080/component/?name=combobox&variant=${variant}&`; +const blockVariantUrl = (variant: string) => + `http://127.0.0.1:8080/component/block/?name=combobox&variant=${variant}&`; const input = (page: Page) => page.getByRole("combobox", { name: "Select framework" }); @@ -230,6 +232,7 @@ test("dynamic option removal updates filtering and keyboard selection", async ({ await page.waitForLoadState('networkidle'); const trigger = page.getByRole("combobox", { name: "Dynamic framework" }); + const toggleSvelte = page.getByRole("button", { name: "Toggle SvelteKit" }); await trigger.click(); await page.keyboard.type("s"); @@ -246,12 +249,13 @@ test("dynamic option removal updates filtering and keyboard selection", async ({ "true", ); - await page.getByRole("button", { name: "Toggle SvelteKit" }).click(); + await expect(trigger).toBeFocused(); + await toggleSvelte.click(); await expect(list(page).getByRole("option", { name: "SvelteKit" })).toHaveCount(0); await expect(list(page).getByRole("option", { name: "SolidStart" })).toBeVisible(); - - await trigger.click(); + await expect(content(page)).toBeVisible(); await expect(trigger).toBeFocused(); + await page.keyboard.press("ArrowDown"); const next = list(page).getByRole("option", { name: "Next.js" }); await expect(next).toHaveAttribute("data-highlighted", "true"); @@ -261,6 +265,119 @@ test("dynamic option removal updates filtering and keyboard selection", async ({ await expect(trigger).toHaveValue("Next.js"); }); +test("virtualized variant shows visible options when opened", async ({ page }) => { + await page.goto(blockVariantUrl("virtualized"), { timeout: 20 * 60 * 1000 }); + await page.waitForLoadState('networkidle'); + + const trigger = page.getByRole("combobox", { name: "Virtualized option picker" }); + await trigger.click(); + + const menu = list(page); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("option", { name: "Option 0", exact: true })).toBeVisible(); + await expect(menu.getByRole("option", { name: "Option 1", exact: true })).toBeVisible(); +}); + +test("virtualized variant keeps scrollHeight stable while scrolling", async ({ page }) => { + await page.goto(blockVariantUrl("virtualized"), { timeout: 20 * 60 * 1000 }); + await page.waitForLoadState('networkidle'); + + const trigger = page.getByRole("combobox", { name: "Virtualized option picker" }); + await trigger.click(); + + const menu = list(page); + await expect(menu).toBeVisible(); + await page.waitForTimeout(500); + + const initialState = await menu.evaluate((el) => ({ + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + ratio: el.scrollHeight / el.clientHeight, + })); + + const maxScroll = initialState.scrollHeight - initialState.clientHeight; + const steps = 20; + const stepSize = maxScroll / steps; + const measurements: Array<{ + scrollTop: number; + scrollHeight: number; + clientHeight: number; + ratio: number; + }> = []; + + for (let i = 1; i <= steps; i++) { + const targetScroll = Math.round(stepSize * i); + + await menu.evaluate((el, scroll) => { + el.scrollTop = scroll; + }, targetScroll); + await page.waitForTimeout(100); + + measurements.push(await menu.evaluate((el) => ({ + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + ratio: el.scrollHeight / el.clientHeight, + }))); + } + + const duringScrollMeasurements = measurements.slice(0, -1); + const scrollHeights = duringScrollMeasurements.map((m) => m.scrollHeight); + const clientHeights = duringScrollMeasurements.map((m) => m.clientHeight); + const ratios = duringScrollMeasurements.map((m) => m.ratio); + const minHeight = Math.min(...scrollHeights); + const maxHeight = Math.max(...scrollHeights); + const heightVariance = maxHeight - minHeight; + const minClientHeight = Math.min(...clientHeights); + const maxClientHeight = Math.max(...clientHeights); + const clientHeightVariance = maxClientHeight - minClientHeight; + const minRatio = Math.min(...ratios); + const maxRatio = Math.max(...ratios); + const ratioVariance = maxRatio - minRatio; + + expect( + heightVariance, + `combobox scrollHeight changed by ${heightVariance}px during scroll` + ).toBeLessThan(100); + expect( + clientHeightVariance, + `combobox clientHeight changed by ${clientHeightVariance}px during scroll` + ).toBeLessThanOrEqual(1); + expect( + ratioVariance, + `combobox scrollHeight/clientHeight ratio changed by ${ratioVariance} during scroll` + ).toBeLessThan(0.5); + + const lastMeasurement = measurements.at(-1); + expect(lastMeasurement).toBeDefined(); + + await page.waitForTimeout(650); + + const settledState = await menu.evaluate((el) => ({ + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + ratio: el.scrollHeight / el.clientHeight, + })); + + expect( + Math.abs(settledState.scrollHeight - lastMeasurement!.scrollHeight), + "combobox scrollHeight shifted after the 600ms scroll debounce settled" + ).toBeLessThan(100); + expect( + Math.abs(settledState.clientHeight - lastMeasurement!.clientHeight), + "combobox clientHeight changed after the 600ms scroll debounce settled" + ).toBeLessThanOrEqual(1); + expect( + Math.abs(settledState.ratio - lastMeasurement!.ratio), + "combobox scrollHeight/clientHeight ratio shifted after the 600ms scroll debounce settled" + ).toBeLessThan(0.5); + expect( + Math.abs(settledState.scrollTop - lastMeasurement!.scrollTop), + "combobox scrollTop drifted after the 600ms scroll debounce settled" + ).toBeLessThanOrEqual(1); +}); + test("touch selection commits and closes", async ({ browser, browserName }) => { test.skip(browserName === "firefox", "Firefox does not support mobile contexts"); diff --git a/preview/src/components/combobox/component.rs b/preview/src/components/combobox/component.rs index fe1fa969..ed4e5c32 100644 --- a/preview/src/components/combobox/component.rs +++ b/preview/src/components/combobox/component.rs @@ -1,7 +1,8 @@ use dioxus::prelude::*; use dioxus_icons::lucide::{Check, ChevronsUpDown}; use dioxus_primitives::combobox::{ - self, default_combobox_filter, ComboboxEmptyProps, ComboboxOptionProps, + self, default_combobox_filter, AutocompleteProps, ComboboxEmptyProps, ComboboxOptionProps, + MultiSelectProps, PillProps, PillsInputProps, TagsInputProps, VirtualizedComboboxOptionsProps, }; use dioxus_primitives::{dioxus_attributes::attributes, merge_attributes}; @@ -61,9 +62,128 @@ pub struct ComboboxProps { pub children: Element, } +#[derive(Props, Clone, PartialEq)] +pub struct VirtualizedComboboxProps { + #[props(default)] + pub value: Option>>, + + #[props(default)] + pub default_value: Option, + + #[props(default)] + pub on_value_change: Callback>, + + #[props(default)] + pub disabled: ReadSignal, + + #[props(default)] + pub open: ReadSignal>, + + #[props(default)] + pub default_open: ReadSignal, + + #[props(default)] + pub on_open_change: Callback, + + #[props(default)] + pub query: ReadSignal>, + + #[props(default)] + pub default_query: ReadSignal, + + #[props(default)] + pub on_query_change: Callback, + + #[props(default = ReadSignal::new(Signal::new(true)))] + pub roving_loop: ReadSignal, + + #[props(default = Callback::new(|(q, t): (String, String)| default_combobox_filter(&q, &t)))] + pub filter: Callback<(String, String), bool>, + + #[props(default)] + pub placeholder: ReadSignal, + + #[props(default)] + pub aria_label: Option, + + #[props(default)] + pub list_aria_label: Option, + + /// The total number of source options before any virtualized visibility mapping is applied. + pub count: ReadSignal, + + #[props(default = ReadSignal::new(Signal::new(8)))] + pub buffer: ReadSignal, + + /// Optional visible-row to source-option index mapping for virtualized filtering. + /// + /// When provided, only these absolute option indices are virtualized and rendered. + #[props(default)] + pub visible_indices: Option>>, + + pub estimate_size: Option>, + + pub render_option: Callback, + + #[props(default)] + pub list_id: ReadSignal>, + + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} + #[component] pub fn Combobox(props: ComboboxProps) -> Element { - let base = attributes!(div { class: Styles::dx_combobox }); + let base = attributes!(div { + class: Styles::dx_combobox + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::Combobox { + value: props.value, + default_value: props.default_value, + on_value_change: props.on_value_change, + disabled: props.disabled, + open: props.open, + default_open: props.default_open, + on_open_change: props.on_open_change, + query: props.query, + default_query: props.default_query, + on_query_change: props.on_query_change, + roving_loop: props.roving_loop, + filter: props.filter, + attributes: merged, + combobox::ComboboxTarget { + class: Styles::dx_combobox_input_wrapper, + combobox::ComboboxSearch { + class: Styles::dx_combobox_input, + placeholder: props.placeholder, + aria_label: props.aria_label.clone(), + } + ChevronsUpDown { + class: Styles::dx_combobox_expand_icon, + size: "16px", + } + } + combobox::ComboboxDropdownTarget { + combobox::ComboboxOptions { + class: Styles::dx_combobox_list, + aria_label: props.list_aria_label.clone(), + {props.children} + } + } + } + } +} + +#[component] +pub fn VirtualizedCombobox( + props: VirtualizedComboboxProps, +) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox + }); let merged = merge_attributes(vec![base, props.attributes]); rsx! { @@ -81,8 +201,9 @@ pub fn Combobox(props: ComboboxProps) -> Elem roving_loop: props.roving_loop, filter: props.filter, attributes: merged, - div { class: Styles::dx_combobox_input_wrapper, - combobox::ComboboxInput { + combobox::ComboboxTarget { + class: Styles::dx_combobox_input_wrapper, + combobox::ComboboxSearch { class: Styles::dx_combobox_input, placeholder: props.placeholder, aria_label: props.aria_label.clone(), @@ -92,18 +213,157 @@ pub fn Combobox(props: ComboboxProps) -> Elem size: "16px", } } - combobox::ComboboxList { - class: Styles::dx_combobox_list, - aria_label: props.list_aria_label.clone(), - {props.children} + combobox::ComboboxDropdownTarget { + combobox::VirtualizedComboboxOptions { + class: Styles::dx_combobox_list, + aria_label: props.list_aria_label.clone(), + count: props.count, + visible_indices: props.visible_indices, + buffer: props.buffer, + estimate_size: props.estimate_size, + render_option: props.render_option, + id: props.list_id, + } } } } } +#[component] +pub fn Autocomplete(props: AutocompleteProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::Autocomplete { + value: props.value, + default_value: props.default_value, + on_value_change: props.on_value_change, + disabled: props.disabled, + open: props.open, + default_open: props.default_open, + on_open_change: props.on_open_change, + query: props.query, + default_query: props.default_query, + on_query_change: props.on_query_change, + roving_loop: props.roving_loop, + filter: props.filter, + placeholder: props.placeholder, + attributes: merged, + {props.children} + } + } +} + +#[component] +pub fn MultiSelect(props: MultiSelectProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::MultiSelect:: { + values: props.values, + default_values: props.default_values, + on_values_change: props.on_values_change, + disabled: props.disabled, + open: props.open, + default_open: props.default_open, + on_open_change: props.on_open_change, + query: props.query, + default_query: props.default_query, + on_query_change: props.on_query_change, + roving_loop: props.roving_loop, + filter: props.filter, + placeholder: props.placeholder, + max_values: props.max_values, + render_value: props.render_value, + attributes: merged, + {props.children} + } + } +} + +#[component] +pub fn PillsInput(props: PillsInputProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox_input + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::PillsInput { + disabled: props.disabled, + attributes: merged, + {props.children} + } + } +} + +#[component] +pub fn Pill(props: PillProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox_pill + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::Pill { + on_remove: props.on_remove, + attributes: merged, + {props.children} + } + } +} + +#[component] +pub fn TagsInput(props: TagsInputProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::TagsInput { + values: props.values, + default_values: props.default_values, + on_values_change: props.on_values_change, + placeholder: props.placeholder, + allow_duplicates: props.allow_duplicates, + disabled: props.disabled, + attributes: merged, + } + } +} + +#[component] +pub fn VirtualizedComboboxOptions(props: VirtualizedComboboxOptionsProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox_list + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::VirtualizedComboboxOptions { + count: props.count, + visible_indices: props.visible_indices, + buffer: props.buffer, + estimate_size: props.estimate_size, + render_option: props.render_option, + id: props.id, + attributes: merged, + } + } +} + #[component] pub fn ComboboxEmpty(props: ComboboxEmptyProps) -> Element { - let base = attributes!(div { class: Styles::dx_combobox_empty }); + let base = attributes!(div { + class: Styles::dx_combobox_empty + }); let merged = merge_attributes(vec![base, props.attributes]); rsx! { @@ -116,7 +376,9 @@ pub fn ComboboxEmpty(props: ComboboxEmptyProps) -> Element { #[component] pub fn ComboboxOption(props: ComboboxOptionProps) -> Element { - let base = attributes!(div { class: Styles::dx_combobox_option }); + let base = attributes!(div { + class: Styles::dx_combobox_option + }); let merged = merge_attributes(vec![base, props.attributes]); rsx! { diff --git a/preview/src/components/combobox/docs.md b/preview/src/components/combobox/docs.md index 6a5fed97..affc9878 100644 --- a/preview/src/components/combobox/docs.md +++ b/preview/src/components/combobox/docs.md @@ -1,9 +1,19 @@ -The Combobox component is an autocomplete input with a filterable popup list. +The Combobox family provides reusable listbox/search interactions for autocomplete-style inputs. +The low-level `Combobox` owns dropdown, highlight, option registry, and submit interaction state; +higher-level components own their own value and query models. Filtering preserves the order defined by the rendered `ComboboxOption` elements and their `index` props. If you want query-dependent ranking, control `query`, sort your item data in user code, render the options in that sorted order, and assign indexes from the sorted list. +## Variants + +- `Combobox` is the low-level selectable autocomplete surface. +- `Autocomplete` owns string input value and maps option submit to the input label. +- `MultiSelect` owns a selected-value array, search query, max-values, and selected pills. +- `TagsInput` owns tag parsing and removable pills. +- `VirtualizedComboboxOptions` renders a listbox with only the visible option window while preserving `ComboboxOption` ids and indexes. + ## Component Structure ```rust @@ -29,3 +39,37 @@ Combobox:: { } } ``` + +## MultiSelect + +```rust +MultiSelect:: { + default_values: vec!["mushroom".to_string()], + max_values: 3usize, + render_value: |value: String| rsx! { "{value}" }, + placeholder: "Pick toppings...", + ComboboxOption:: { + index: 0usize, + value: "mushroom".to_string(), + text_value: "Mushroom", + "Mushroom" + } +} +``` + +## Virtualized Options + +```rust +VirtualizedCombobox:: { + count: 1000usize, + estimate_size: |_: usize| 36, + render_option: |index: usize| rsx! { + ComboboxOption:: { + index, + value: format!("option-{index}"), + text_value: format!("Option {index}"), + "Option {index}" + } + } +} +``` diff --git a/preview/src/components/combobox/style.css b/preview/src/components/combobox/style.css index 86734e70..f56266e2 100644 --- a/preview/src/components/combobox/style.css +++ b/preview/src/components/combobox/style.css @@ -3,12 +3,36 @@ display: inline-block; } -.dx-combobox-input-wrapper { +.dx-combobox-input-wrapper, +.dx-combobox [data-combobox-target] { position: relative; width: 200px; } -.dx-combobox-input { +.dx-combobox[data-pills-input], +.dx-combobox [data-pills-input] { + display: flex; + width: 200px; + min-height: 2.25rem; + box-sizing: border-box; + flex-wrap: wrap; + align-items: center; + padding: 0.25rem; + border-radius: 0.5rem; + background: var(--light, var(--primary-color)) var(--dark, var(--primary-color-3)); + box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) var(--dark, var(--primary-color-7)); + gap: 0.25rem; +} + +.dx-combobox[data-pills-input]:focus-within, +.dx-combobox[data-pills-input][data-state="open"], +.dx-combobox [data-pills-input]:focus-within, +.dx-combobox [data-pills-input][data-state="open"] { + background: var(--light, var(--primary-color-4)) var(--dark, var(--primary-color-5)); +} + +.dx-combobox-input, +.dx-combobox [data-combobox-search] { width: 100%; height: 2.25rem; box-sizing: border-box; @@ -25,18 +49,56 @@ outline: none; } +.dx-combobox[data-pills-input] [data-combobox-search], +.dx-combobox[data-pills-input] [data-pills-input-field], +.dx-combobox [data-pills-input] [data-combobox-search], +.dx-combobox [data-pills-input] [data-pills-input-field] { + width: auto; + min-width: 5rem; + height: 1.5rem; + flex: 1 1 6rem; + padding: 0 0.25rem; + border: 0; + border-radius: 0; + appearance: none; + background: transparent; + box-shadow: none; + color: var(--secondary-color-1); + outline: 0; +} + +.dx-combobox[data-pills-input] [data-combobox-search]:focus-visible, +.dx-combobox[data-pills-input] [data-combobox-search][data-state="open"], +.dx-combobox[data-pills-input] [data-pills-input-field]:focus-visible, +.dx-combobox [data-pills-input] [data-combobox-search]:focus-visible, +.dx-combobox [data-pills-input] [data-combobox-search][data-state="open"], +.dx-combobox [data-pills-input] [data-pills-input-field]:focus-visible { + background: transparent; + box-shadow: none; + outline: 0; +} + +.dx-combobox[data-pills-input] [data-pills-input-field]::placeholder, +.dx-combobox [data-pills-input] [data-pills-input-field]::placeholder { + color: var(--secondary-color-5); +} + .dx-combobox-input:hover:not([disabled]), .dx-combobox-input:focus-visible, -.dx-combobox-input[data-state="open"] { +.dx-combobox-input[data-state="open"], +.dx-combobox [data-combobox-search]:focus-visible, +.dx-combobox [data-combobox-search][data-state="open"] { background: var(--light, var(--primary-color-4)) var(--dark, var(--primary-color-5)); } -.dx-combobox-input[disabled] { +.dx-combobox-input[disabled], +.dx-combobox [data-combobox-search][disabled] { cursor: not-allowed; opacity: 0.5; } -.dx-combobox-input::placeholder { +.dx-combobox-input::placeholder, +.dx-combobox [data-combobox-search]::placeholder { color: var(--secondary-color-5); } @@ -49,7 +111,8 @@ transform: translateY(-50%); } -.dx-combobox-list { +.dx-combobox-list, +.dx-combobox [role="listbox"] { position: absolute; z-index: 50; top: calc(100% + 0.25rem); @@ -67,11 +130,13 @@ transform-origin: top; } -.dx-combobox-list[data-state="open"] { +.dx-combobox-list[data-state="open"], +.dx-combobox [role="listbox"][data-state="open"] { animation: dx-picker-in 150ms ease-out forwards; } -.dx-combobox-list[data-state="closed"] { +.dx-combobox-list[data-state="closed"], +.dx-combobox [role="listbox"][data-state="closed"] { animation: dx-picker-out 100ms ease-in forwards; pointer-events: none; } @@ -106,7 +171,8 @@ text-align: center; } -.dx-combobox-option { +.dx-combobox-option, +.dx-combobox [role="option"] { display: flex; align-items: center; padding: 0.375rem 0.5rem; @@ -119,12 +185,14 @@ user-select: none; } -.dx-combobox-option[data-highlighted="true"] { +.dx-combobox-option[data-highlighted="true"], +.dx-combobox [role="option"][data-highlighted="true"] { background: var(--light, var(--primary-color-4)) var(--dark, var(--primary-color-7)); color: var(--secondary-color-1); } -.dx-combobox-option[data-disabled="true"] { +.dx-combobox-option[data-disabled="true"], +.dx-combobox [role="option"][data-disabled="true"] { cursor: not-allowed; opacity: 0.5; pointer-events: none; @@ -134,3 +202,47 @@ margin-left: auto; color: var(--secondary-color-5); } + +.dx-combobox-pill, +.dx-combobox [data-pill] { + display: inline-flex; + max-width: 100%; + height: 1.5rem; + box-sizing: border-box; + align-items: center; + padding: 0 0.25rem 0 0.5rem; + border-radius: 0.375rem; + background: var(--light, var(--primary-color-4)) var(--dark, var(--primary-color-6)); + color: var(--secondary-color-1); + font-size: 0.75rem; + gap: 0.25rem; + line-height: 1rem; +} + +.dx-combobox-pill button, +.dx-combobox [data-pill] button { + display: inline-grid; + width: 1rem; + height: 1rem; + padding: 0; + border: none; + border-radius: 0.25rem; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + place-items: center; +} + +.dx-combobox-demo-stack { + display: grid; + max-width: 24rem; + gap: 0.75rem; +} + +.dx-combobox-demo-value { + margin: 0; + color: var(--secondary-color-5); + font-size: 0.875rem; + line-height: 1.25rem; +} diff --git a/preview/src/components/combobox/variants/autocomplete/mod.rs b/preview/src/components/combobox/variants/autocomplete/mod.rs new file mode 100644 index 00000000..b37389a7 --- /dev/null +++ b/preview/src/components/combobox/variants/autocomplete/mod.rs @@ -0,0 +1,38 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut value = use_signal(|| None::); + let frameworks: &[(&str, &str)] = &[ + ("next", "Next.js"), + ("svelte", "SvelteKit"), + ("nuxt", "Nuxt.js"), + ("remix", "Remix"), + ("astro", "Astro"), + ("solid", "SolidStart"), + ("dioxus", "Dioxus"), + ]; + + rsx! { + div { class: "dx-combobox-demo-stack", + Autocomplete { + value: Some(value.into()), + on_value_change: move |next| value.set(next), + placeholder: "Type a framework...", + ComboboxEmpty { "No framework found." } + for (index, (value, label)) in frameworks.iter().enumerate() { + ComboboxOption:: { + index, + value: value.to_string(), + text_value: label.to_string(), + {*label} + } + } + } + p { class: "dx-combobox-demo-value", + "Selected: {value().unwrap_or_else(|| \"none\".to_string())}" + } + } + } +} diff --git a/preview/src/components/combobox/variants/dynamic/mod.rs b/preview/src/components/combobox/variants/dynamic/mod.rs index 35d7d892..5d80fbda 100644 --- a/preview/src/components/combobox/variants/dynamic/mod.rs +++ b/preview/src/components/combobox/variants/dynamic/mod.rs @@ -11,14 +11,32 @@ pub fn Demo() -> Element { div { style: "display: flex; gap: 0.5rem;", button { r#type: "button", - onpointerdown: move |event| event.prevent_default(), - onclick: move |_| show_svelte.toggle(), + onpointerdown: move |event| { + event.prevent_default(); + show_svelte.toggle(); + }, + onkeydown: move |event| { + let key = event.key(); + if matches!(key, Key::Enter) || matches!(key, Key::Character(ch) if ch == " ") { + event.prevent_default(); + show_svelte.toggle(); + } + }, "Toggle SvelteKit" } button { r#type: "button", - onpointerdown: move |event| event.prevent_default(), - onclick: move |_| show_solid.toggle(), + onpointerdown: move |event| { + event.prevent_default(); + show_solid.toggle(); + }, + onkeydown: move |event| { + let key = event.key(); + if matches!(key, Key::Enter) || matches!(key, Key::Character(ch) if ch == " ") { + event.prevent_default(); + show_solid.toggle(); + } + }, "Toggle SolidStart" } } diff --git a/preview/src/components/combobox/variants/multi_select/mod.rs b/preview/src/components/combobox/variants/multi_select/mod.rs new file mode 100644 index 00000000..babe625a --- /dev/null +++ b/preview/src/components/combobox/variants/multi_select/mod.rs @@ -0,0 +1,38 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut values = use_signal(|| Some(vec!["mushroom".to_string()])); + let toppings: &[(&str, &str)] = &[ + ("pepperoni", "Pepperoni"), + ("mushroom", "Mushroom"), + ("onion", "Onion"), + ("olive", "Olive"), + ("jalapeno", "Jalapeno"), + ]; + + rsx! { + div { class: "dx-combobox-demo-stack", + MultiSelect:: { + values, + on_values_change: move |next| values.set(Some(next)), + max_values: 3usize, + render_value: |value: String| rsx! { "{value}" }, + placeholder: "Pick toppings...", + ComboboxEmpty { "No toppings found." } + for (index, (value, label)) in toppings.iter().enumerate() { + ComboboxOption:: { + index, + value: value.to_string(), + text_value: label.to_string(), + {*label} + } + } + } + p { class: "dx-combobox-demo-value", + "Selected: {values().unwrap_or_default().join(\", \")}" + } + } + } +} diff --git a/preview/src/components/combobox/variants/tags_input/mod.rs b/preview/src/components/combobox/variants/tags_input/mod.rs new file mode 100644 index 00000000..61e12b9e --- /dev/null +++ b/preview/src/components/combobox/variants/tags_input/mod.rs @@ -0,0 +1,20 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut values = use_signal(|| Some(vec!["dioxus".to_string(), "components".to_string()])); + + rsx! { + div { class: "dx-combobox-demo-stack", + TagsInput { + values, + on_values_change: move |next| values.set(Some(next)), + placeholder: "Add tag and press Enter...", + } + p { class: "dx-combobox-demo-value", + "Tags: {values().unwrap_or_default().join(\", \")}" + } + } + } +} diff --git a/preview/src/components/combobox/variants/virtualized/mod.rs b/preview/src/components/combobox/variants/virtualized/mod.rs new file mode 100644 index 00000000..265bf643 --- /dev/null +++ b/preview/src/components/combobox/variants/virtualized/mod.rs @@ -0,0 +1,43 @@ +use super::super::component::*; +use dioxus::prelude::*; +use dioxus_primitives::combobox::default_combobox_filter; + +#[component] +pub fn Demo() -> Element { + let mut value = use_signal(|| None::); + let mut query = use_signal(String::new); + let visible_indices = use_memo(move || { + let query = query.read().clone(); + (0..1000) + .filter(|index| default_combobox_filter(&query, &format!("Option {index}"))) + .collect::>() + }); + + rsx! { + div { class: "dx-combobox-demo-stack", + VirtualizedCombobox:: { + value: Some(value.into()), + on_value_change: move |next| value.set(next), + query: Some(query()), + on_query_change: move |next| query.set(next), + placeholder: "Search 1,000 options...", + aria_label: "Virtualized option picker", + list_aria_label: "Virtualized options", + count: 1000usize, + visible_indices: Some(visible_indices.into()), + estimate_size: |_: usize| 36, + render_option: |index: usize| rsx! { + ComboboxOption:: { + index, + value: format!("option-{index}"), + text_value: format!("Option {index}"), + "Option {index}" + } + }, + } + p { class: "dx-combobox-demo-value", + "Selected: {value().unwrap_or_else(|| \"none\".to_string())}" + } + } + } +} diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index cad937b4..0ecf1f51 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -181,7 +181,7 @@ examples!( checkbox, collapsible, color_picker, - combobox[controlled, disabled, dynamic], + combobox[controlled, disabled, dynamic, autocomplete, multi_select, tags_input, virtualized], context_menu, date_picker[internationalized, range, multi_month, unavailable_dates], dialog, diff --git a/preview/src/main.rs b/preview/src/main.rs index c5fb6f40..a5bd5e6f 100644 --- a/preview/src/main.rs +++ b/preview/src/main.rs @@ -1267,10 +1267,17 @@ fn WidgetMasonry() -> Element { } #[allow(unpredictable_function_pointer_comparisons)] +#[derive(Props, Clone, PartialEq)] +struct MasonryCardProps { + component: fn() -> Element, + #[props(default)] + popout: bool, +} + #[component] -fn MasonryCard(component: fn() -> Element, #[props(default)] popout: bool) -> Element { - let Comp = component; - let class = if popout { +fn MasonryCard(props: MasonryCardProps) -> Element { + let Comp = props.component; + let class = if props.popout { "dx-widget-card dx-widget-card-popout" } else { "dx-widget-card" diff --git a/primitives/src/combobox/components/combobox.rs b/primitives/src/combobox/components/combobox.rs index ba97c5b6..7565de92 100644 --- a/primitives/src/combobox/components/combobox.rs +++ b/primitives/src/combobox/components/combobox.rs @@ -3,9 +3,11 @@ use dioxus::prelude::*; use super::super::context::{default_combobox_filter, ComboboxContext}; +use super::super::hook::{use_combobox, UseComboboxOptions}; use crate::{ selectable::{ - use_selectable_root, use_single_selectable_value, RcPartialEqValue, SelectionMode, + use_selectable_root_with_state, use_single_selectable_value, RcPartialEqValue, + SelectionMode, }, use_controlled, Controlled, }; @@ -70,34 +72,55 @@ pub struct ComboboxProps { pub children: Element, } -fn use_combobox_root( +pub(super) fn use_combobox_root( values: Memo>, set_value: Callback, - disabled: ReadSignal, - roving_loop: ReadSignal, - open: Controlled, - query: Controlled, - filter: Callback<(String, String), bool>, + config: ComboboxRootConfig, ) -> Memo { - let selectable = use_selectable_root( + let ComboboxRootConfig { + selection_mode, + disabled, + roving_loop, + open, + query, + filter, + } = config; + let store = use_combobox(UseComboboxOptions { + opened: open.value, + default_opened: open.default, + on_opened_change: open.on_change, + loop_navigation: roving_loop, + ..Default::default() + }); + let store_open = use_memo(move || store.dropdown_opened()); + let selectable = use_selectable_root_with_state( values, set_value, - SelectionMode::Single, + selection_mode, disabled, roving_loop, - open, + store_open, + Callback::new(move |_| {}), ); let (query, set_query) = use_controlled(query.value, query.default.cloned(), query.on_change); - let open = selectable.open; - use_context_provider(|| ComboboxContext { selectable, + store, query, set_query, filter, }); - open + use_memo(move || store.dropdown_opened()) +} + +pub(super) struct ComboboxRootConfig { + pub(super) selection_mode: SelectionMode, + pub(super) disabled: ReadSignal, + pub(super) roving_loop: ReadSignal, + pub(super) open: Controlled, + pub(super) query: Controlled, + pub(super) filter: Callback<(String, String), bool>, } /// A single-select autocomplete input with a filterable popup list. @@ -113,19 +136,22 @@ pub fn Combobox(props: ComboboxProps) -> Elem let open = use_combobox_root( selected, set_value, - props.disabled, - props.roving_loop, - Controlled { - value: props.open, - default: props.default_open, - on_change: props.on_open_change, - }, - Controlled { - value: props.query, - default: props.default_query, - on_change: props.on_query_change, + ComboboxRootConfig { + selection_mode: SelectionMode::Single, + disabled: props.disabled, + roving_loop: props.roving_loop, + open: Controlled { + value: props.open, + default: props.default_open, + on_change: props.on_open_change, + }, + query: Controlled { + value: props.query, + default: props.default_query, + on_change: props.on_query_change, + }, + filter: props.filter, }, - props.filter, ); rsx! { diff --git a/primitives/src/combobox/components/high_level.rs b/primitives/src/combobox/components/high_level.rs new file mode 100644 index 00000000..0d16f41a --- /dev/null +++ b/primitives/src/combobox/components/high_level.rs @@ -0,0 +1,435 @@ +//! Higher-level combobox-based input components. + +use core::panic; + +use dioxus::prelude::*; + +use super::combobox::use_combobox_root; +use super::{ + Combobox, ComboboxDropdownTarget, ComboboxInput, ComboboxOptions, ComboboxSearch, + ComboboxTarget, +}; +use crate::{ + combobox::context::default_combobox_filter, + selectable::{RcPartialEqValue, SelectionMode}, + use_controlled, Controlled, +}; + +/// Props for [`Autocomplete`]. +#[derive(Props, Clone, PartialEq)] +pub struct AutocompleteProps { + /// The controlled input value. + #[props(default)] + pub value: Option>>, + + /// The initial uncontrolled input value. + #[props(default)] + pub default_value: Option, + + /// Callback fired when the input value changes. + #[props(default)] + pub on_value_change: Callback>, + + /// Whether the autocomplete is disabled. + #[props(default)] + pub disabled: ReadSignal, + + /// The controlled open state of the popup. + #[props(default)] + pub open: ReadSignal>, + + /// The initial open state when uncontrolled. + #[props(default)] + pub default_open: ReadSignal, + + /// Callback fired when the popup open state changes. + #[props(default)] + pub on_open_change: Callback, + + /// The controlled text query used to filter options. + #[props(default)] + pub query: ReadSignal>, + + /// The initial text query when uncontrolled. + #[props(default)] + pub default_query: ReadSignal, + + /// Callback fired when the text query changes. + #[props(default)] + pub on_query_change: Callback, + + /// Whether arrow-key navigation should wrap. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub roving_loop: ReadSignal, + + /// Custom filter callback. Receives `(query, option_text_value)`. + #[props(default = Callback::new(|(q, t): (String, String)| default_combobox_filter(&q, &t)))] + pub filter: Callback<(String, String), bool>, + + /// Placeholder shown when the input is empty. + #[props(default)] + pub placeholder: ReadSignal, + + /// Additional attributes for the root element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Option children. + pub children: Element, +} + +/// A string autocomplete built on top of [`Combobox`]. +#[component] +pub fn Autocomplete(props: AutocompleteProps) -> Element { + rsx! { + Combobox:: { + value: props.value, + default_value: props.default_value, + on_value_change: props.on_value_change, + disabled: props.disabled, + open: props.open, + default_open: props.default_open, + on_open_change: props.on_open_change, + query: props.query, + default_query: props.default_query, + on_query_change: props.on_query_change, + roving_loop: props.roving_loop, + filter: props.filter, + attributes: props.attributes, + ComboboxInput { + placeholder: props.placeholder, + } + ComboboxOptions { + {props.children} + } + } + } +} + +/// Props for [`MultiSelect`]. +#[derive(Props, Clone, PartialEq)] +pub struct MultiSelectProps { + /// The controlled list of selected values. + #[props(default)] + pub values: ReadSignal>>, + + /// The default list of selected values. + #[props(default)] + pub default_values: Vec, + + /// Callback when the list of selected values changes. + #[props(default)] + pub on_values_change: Callback>, + + /// Whether the multi-select is disabled. + #[props(default)] + pub disabled: ReadSignal, + + /// The controlled open state of the popup. + #[props(default)] + pub open: ReadSignal>, + + /// The initial open state when uncontrolled. + #[props(default)] + pub default_open: ReadSignal, + + /// Callback fired when the popup open state changes. + #[props(default)] + pub on_open_change: Callback, + + /// The controlled search query. + #[props(default)] + pub query: ReadSignal>, + + /// The initial search query when uncontrolled. + #[props(default)] + pub default_query: ReadSignal, + + /// Callback fired when the search query changes. + #[props(default)] + pub on_query_change: Callback, + + /// Whether arrow-key navigation should wrap. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub roving_loop: ReadSignal, + + /// Custom filter callback. Receives `(query, option_text_value)`. + #[props(default = Callback::new(|(q, t): (String, String)| default_combobox_filter(&q, &t)))] + pub filter: Callback<(String, String), bool>, + + /// Search placeholder. + #[props(default)] + pub placeholder: ReadSignal, + + /// Maximum number of selected values. + #[props(default)] + pub max_values: Option, + + /// Renders a selected value as a pill inside the target. + #[props(default)] + pub render_value: Option>, + + /// Additional attributes for the root element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Option children. + pub children: Element, +} + +/// A searchable multi-select built on the combobox store. +#[component] +pub fn MultiSelect(props: MultiSelectProps) -> Element { + let (values_state, set_values) = + use_controlled(props.values, props.default_values, props.on_values_change); + let values = use_memo(move || { + values_state() + .into_iter() + .map(RcPartialEqValue::new) + .collect() + }); + let set_value = use_callback(move |incoming: RcPartialEqValue| { + let value = incoming + .as_ref::() + .unwrap_or_else(|| panic!("MultiSelect and option value types must match")) + .clone(); + let mut current = values_state(); + if let Some(index) = current.iter().position(|item| item == &value) { + current.remove(index); + } else { + if props + .max_values + .is_some_and(|max_values| current.len() >= max_values) + { + return; + } + current.push(value); + } + set_values.call(current); + }); + + let open = use_combobox_root( + values, + set_value, + super::combobox::ComboboxRootConfig { + selection_mode: SelectionMode::Multiple, + disabled: props.disabled, + roving_loop: props.roving_loop, + open: Controlled { + value: props.open, + default: props.default_open, + on_change: props.on_open_change, + }, + query: Controlled { + value: props.query, + default: props.default_query, + on_change: props.on_query_change, + }, + filter: props.filter, + }, + ); + + rsx! { + div { + "data-state": if open() { "open" } else { "closed" }, + "data-disabled": (props.disabled)(), + ..props.attributes, + ComboboxTarget { + "data-pills-input": true, + if let Some(render_value) = props.render_value { + for (index, value) in values_state().into_iter().enumerate() { + Pill { + key: "{index}", + on_remove: Some(Callback::new(move |_| { + let mut next = values_state(); + if index < next.len() { + next.remove(index); + set_values.call(next); + } + })), + {render_value.call(value)} + } + } + } + ComboboxSearch { + placeholder: props.placeholder, + show_selected_text: false, + } + } + ComboboxDropdownTarget { + ComboboxOptions { + {props.children} + } + } + } + } +} + +/// Props for [`PillsInput`]. +#[derive(Props, Clone, PartialEq)] +pub struct PillsInputProps { + /// Whether the input is disabled. + #[props(default)] + pub disabled: ReadSignal, + + /// Additional attributes for the root element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Pill and input children. + pub children: Element, +} + +/// A layout primitive for pill-based inputs. +#[component] +pub fn PillsInput(props: PillsInputProps) -> Element { + rsx! { + div { + role: "group", + "data-pills-input": true, + "data-disabled": (props.disabled)(), + ..props.attributes, + {props.children} + } + } +} + +/// Props for [`Pill`]. +#[derive(Props, Clone, PartialEq)] +pub struct PillProps { + /// Callback fired when the remove button is pressed. + #[props(default)] + pub on_remove: Option>, + + /// Additional attributes for the pill. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Pill label. + pub children: Element, +} + +/// A removable pill item. +#[component] +pub fn Pill(props: PillProps) -> Element { + rsx! { + span { + "data-pill": true, + ..props.attributes, + {props.children} + if let Some(on_remove) = props.on_remove { + button { + type: "button", + aria_label: "Remove", + onclick: move |_| on_remove.call(()), + "×" + } + } + } + } +} + +/// Props for [`TagsInput`]. +#[derive(Props, Clone, PartialEq)] +pub struct TagsInputProps { + /// The controlled tag list. + #[props(default)] + pub values: ReadSignal>>, + + /// The default tag list. + #[props(default)] + pub default_values: Vec, + + /// Callback fired when tags change. + #[props(default)] + pub on_values_change: Callback>, + + /// Placeholder for the text input. + #[props(default)] + pub placeholder: ReadSignal, + + /// Whether duplicate tags are allowed. + #[props(default)] + pub allow_duplicates: ReadSignal, + + /// Whether the tags input is disabled. + #[props(default)] + pub disabled: ReadSignal, + + /// Additional attributes for the root element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} + +/// A basic tags input that owns tag parsing and pill removal. +#[component] +pub fn TagsInput(props: TagsInputProps) -> Element { + let (values, set_values) = + use_controlled(props.values, props.default_values, props.on_values_change); + let mut input = use_signal(String::new); + + let add_tag = use_callback(move |tag: String| { + let tag = tag.trim().to_string(); + if tag.is_empty() { + return; + } + let mut next = values(); + if !(props.allow_duplicates)() && next.iter().any(|item| item == &tag) { + return; + } + next.push(tag); + set_values.call(next); + }); + + rsx! { + PillsInput { + disabled: props.disabled, + attributes: props.attributes, + for (index, tag) in values().into_iter().enumerate() { + Pill { + key: "{tag}-{index}", + on_remove: Some(Callback::new(move |_| { + let mut next = values(); + if index < next.len() { + next.remove(index); + set_values.call(next); + } + })), + "{tag}" + } + } + input { + type: "text", + "data-pills-input-field": true, + disabled: (props.disabled)(), + value: input(), + placeholder: props.placeholder, + oninput: move |event| { + input.set(event.value()); + }, + onkeydown: move |event| { + let key = event.key(); + let should_add = matches!(key, Key::Enter) + || matches!(key, Key::Character(ref value) if value == ","); + if should_add { + add_tag.call(input()); + input.set(String::new()); + event.prevent_default(); + event.stop_propagation(); + return; + } + + match key { + Key::Backspace if input().is_empty() => { + let mut next = values(); + if next.pop().is_some() { + set_values.call(next); + } + } + _ => {} + } + }, + } + } + } +} diff --git a/primitives/src/combobox/components/input.rs b/primitives/src/combobox/components/input.rs index 286d3af5..43f3b44a 100644 --- a/primitives/src/combobox/components/input.rs +++ b/primitives/src/combobox/components/input.rs @@ -1,9 +1,8 @@ -//! Combobox input component. +//! Combobox input and search components. use dioxus::prelude::*; -use super::super::context::ComboboxContext; -use crate::{use_id_or, use_unique_id}; +use super::target::render_combobox_search; /// Props for [`ComboboxInput`]. #[derive(Props, Clone, PartialEq)] @@ -21,130 +20,40 @@ pub struct ComboboxInputProps { pub attributes: Vec, } -/// The text input that opens and filters the popup list. +/// Compatibility input that acts as the target, events target, and search input. #[component] pub fn ComboboxInput(props: ComboboxInputProps) -> Element { - let mut ctx = use_context::(); - - let id = use_unique_id(); - let id = use_id_or(id, props.id); - - let open = ctx.selectable.open; - let query = ctx.query; - let set_query = ctx.set_query; - - let active_descendant = use_memo(move || { - if !open() { - return None; - } - ctx.focused_option_id() - }); - - let display_value = use_memo(move || { - if open() { - query.cloned() - } else { - ctx.selectable.selected_text().unwrap_or_default() - } - }); - - let onkeydown = move |event: KeyboardEvent| match event.key() { - Key::ArrowDown => { - if !open() { - ctx.open_with_empty_query_and_focus_first(); - } else { - ctx.focus_next_visible(); - } - event.prevent_default(); - event.stop_propagation(); - } - Key::ArrowUp => { - if !open() { - ctx.open_with_empty_query_and_focus_last(); - } else { - ctx.focus_prev_visible(); - } - event.prevent_default(); - event.stop_propagation(); - } - Key::Home if open() => { - ctx.focus_first_visible(); - event.prevent_default(); - event.stop_propagation(); - } - Key::End if open() => { - ctx.focus_last_visible(); - event.prevent_default(); - event.stop_propagation(); - } - Key::Enter if open() => { - ctx.select_focused(); - event.prevent_default(); - event.stop_propagation(); - } - Key::Escape if open() => { - ctx.set_open(false); - event.prevent_default(); - event.stop_propagation(); - } - _ => {} - }; + render_combobox_search(props.placeholder, props.id, props.attributes, true, true) +} - rsx! { - input { - id, - r#type: "text", - value: display_value(), - placeholder: props.placeholder, - autocomplete: "off", - spellcheck: "false", - disabled: (ctx.selectable.disabled)(), +/// Props for [`ComboboxSearch`]. +#[derive(Props, Clone, PartialEq)] +pub struct ComboboxSearchProps { + /// Placeholder shown when the input is empty. + #[props(default)] + pub placeholder: ReadSignal, - role: "combobox", - aria_autocomplete: "list", - aria_haspopup: "listbox", - aria_expanded: open(), - aria_controls: ctx.selectable.list_id, - aria_activedescendant: active_descendant(), + /// Optional id for the input element. + #[props(default)] + pub id: ReadSignal>, - "data-state": if open() { "open" } else { "closed" }, + /// Whether to show selected option text while the dropdown is closed. + #[props(default = true)] + pub show_selected_text: bool, - onclick: move |_| { - if !open() { - set_query.call(String::new()); - ctx.set_open(true); - } - }, - oninput: move |event| { - let was_open = open(); - let value = event.value(); - let next_query = if was_open { - value - } else { - ctx.selectable - .selected_text() - .and_then(|selected| { - value - .strip_prefix(&selected) - .map(ToString::to_string) - }) - .unwrap_or(value) - }; - set_query.call(next_query); - if was_open { - ctx.selectable.focus_state.set_focus(None); - } else { - ctx.set_open(true); - } - }, - onkeydown, - onblur: move |_| { - if open() { - ctx.set_open(false); - } - }, + /// Additional attributes. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} - ..props.attributes, - } - } +/// Search input for split combobox anatomy. +#[component] +pub fn ComboboxSearch(props: ComboboxSearchProps) -> Element { + render_combobox_search( + props.placeholder, + props.id, + props.attributes, + false, + props.show_selected_text, + ) } diff --git a/primitives/src/combobox/components/list.rs b/primitives/src/combobox/components/list.rs index 95fae400..915c0384 100644 --- a/primitives/src/combobox/components/list.rs +++ b/primitives/src/combobox/components/list.rs @@ -1,13 +1,13 @@ -//! ComboboxList component. +//! Combobox options components. use dioxus::prelude::*; use super::super::context::ComboboxContext; -use crate::listbox::use_listbox_container; +use crate::listbox::{use_listbox_container_with_open, use_listbox_id}; -/// Props for [`ComboboxList`]. +/// Props for [`ComboboxOptions`]. #[derive(Props, Clone, PartialEq)] -pub struct ComboboxListProps { +pub struct ComboboxOptionsProps { /// Optional id for the list element. #[props(default)] pub id: ReadSignal>, @@ -23,10 +23,11 @@ pub struct ComboboxListProps { /// Listbox that contains the visible options. #[component] -pub fn ComboboxList(props: ComboboxListProps) -> Element { +pub fn ComboboxOptions(props: ComboboxOptionsProps) -> Element { let ctx = use_context::(); - let open = ctx.selectable.open; - let listbox = use_listbox_container(props.id, ctx.selectable); + let open = use_memo(move || ctx.store.dropdown_opened()); + let id = use_listbox_id(props.id, ctx.selectable.list_id); + let listbox = use_listbox_container_with_open(id, ctx.selectable, open); let render = listbox.render; rsx! { @@ -46,3 +47,18 @@ pub fn ComboboxList(props: ComboboxListProps) -> Element { } } } + +/// Compatibility props alias for [`ComboboxOptions`]. +pub type ComboboxListProps = ComboboxOptionsProps; + +/// Compatibility alias for [`ComboboxOptions`]. +#[component] +pub fn ComboboxList(props: ComboboxListProps) -> Element { + rsx! { + ComboboxOptions { + id: props.id, + attributes: props.attributes, + {props.children} + } + } +} diff --git a/primitives/src/combobox/components/mod.rs b/primitives/src/combobox/components/mod.rs index f46c6693..76512678 100644 --- a/primitives/src/combobox/components/mod.rs +++ b/primitives/src/combobox/components/mod.rs @@ -2,14 +2,31 @@ pub mod combobox; pub mod empty; +pub mod high_level; pub mod input; pub mod list; pub mod option; +pub mod target; +pub mod virtualized; pub use combobox::{Combobox, ComboboxProps}; pub use empty::{ComboboxEmpty, ComboboxEmptyProps}; -pub use input::{ComboboxInput, ComboboxInputProps}; -pub use list::{ComboboxList, ComboboxListProps}; +pub use high_level::{ + Autocomplete, AutocompleteProps, MultiSelect, MultiSelectProps, Pill, PillProps, PillsInput, + PillsInputProps, TagsInput, TagsInputProps, +}; +pub use input::{ComboboxInput, ComboboxInputProps, ComboboxSearch, ComboboxSearchProps}; +pub use list::{ComboboxList, ComboboxListProps, ComboboxOptions, ComboboxOptionsProps}; pub use option::{ ComboboxItemIndicator, ComboboxItemIndicatorProps, ComboboxOption, ComboboxOptionProps, }; +pub use target::{ + use_combobox_dropdown_target, use_combobox_dropdown_target_attributes, + use_combobox_events_target, use_combobox_events_target_attributes, use_combobox_search, + use_combobox_search_attributes, use_combobox_target, use_combobox_target_attributes, + ComboboxDropdownTarget, ComboboxDropdownTargetHandle, ComboboxDropdownTargetProps, + ComboboxEventsTarget, ComboboxEventsTargetHandle, ComboboxEventsTargetProps, + ComboboxSearchHandle, ComboboxTarget, ComboboxTargetHandle, ComboboxTargetProps, + UseComboboxSearchOptions, +}; +pub use virtualized::{VirtualizedComboboxOptions, VirtualizedComboboxOptionsProps}; diff --git a/primitives/src/combobox/components/option.rs b/primitives/src/combobox/components/option.rs index e40c9d8c..099c5196 100644 --- a/primitives/src/combobox/components/option.rs +++ b/primitives/src/combobox/components/option.rs @@ -2,13 +2,15 @@ use dioxus::prelude::*; -use super::super::context::ComboboxContext; +use super::super::{context::ComboboxContext, hook::ComboboxDropdownEventSource}; use crate::{ listbox::{ListboxContext, ListboxItemIndicator}, selectable::{ pointer_select_cancel, pointer_select_commit, pointer_select_start, use_selectable_option, - RcPartialEqValue, SelectableOptionConfig, + SelectableOptionConfig, }, + selection::option_text_value, + use_effect_with_cleanup, }; /// Props for [`ComboboxOption`]. @@ -54,7 +56,10 @@ pub fn ComboboxOption(props: ComboboxOptionProps let index = props.index; let mut ctx: ComboboxContext = use_context(); - let visible = move || ctx.is_visible(index()); + let text_value = use_memo(move || { + option_text_value(&*props.value.read(), (props.text_value)(), "ComboboxOption") + }); + let visible = move || ctx.is_visible_text(index(), text_value.cloned()); let option = use_selectable_option( ctx.selectable, SelectableOptionConfig { @@ -66,6 +71,17 @@ pub fn ComboboxOption(props: ComboboxOptionProps component_name: "ComboboxOption", }, ); + use_effect_with_cleanup({ + let store = ctx.store; + let id = option.id; + let disabled = option.disabled; + let selected = option.selected; + move || { + let id_value = id.cloned(); + store.register_option(id_value.clone(), index(), disabled(), visible(), selected()); + move || store.unregister_option(&id_value) + } + }); let render = use_context::().render; @@ -87,6 +103,7 @@ pub fn ComboboxOption(props: ComboboxOptionProps onmouseenter: move |_| { if !(option.disabled)() { ctx.selectable.focus_state.set_focus(Some((option.index)())); + ctx.store.select_option((option.index)()); } }, onpointerdown: move |event| { @@ -94,7 +111,7 @@ pub fn ComboboxOption(props: ComboboxOptionProps }, onpointerup: move |event| { if pointer_select_commit(&event, (option.disabled)(), option.down_pos) { - ctx.selectable.select_value(RcPartialEqValue::new(option.value.cloned())); + ctx.submit_index((option.index)(), ComboboxDropdownEventSource::Mouse); } }, onpointercancel: move |_| { diff --git a/primitives/src/combobox/components/target.rs b/primitives/src/combobox/components/target.rs new file mode 100644 index 00000000..17eef114 --- /dev/null +++ b/primitives/src/combobox/components/target.rs @@ -0,0 +1,551 @@ +//! Combobox target anatomy components. + +use dioxus::prelude::*; + +use super::super::{context::ComboboxContext, hook::ComboboxDropdownEventSource}; +use crate::{dioxus_attributes::attributes, merge_attributes, use_unique_id}; + +fn active_descendant(ctx: ComboboxContext, open: Memo) -> Memo> { + use_memo(move || { + if !open() { + return None; + } + ctx.focused_option_id() + }) +} + +fn handle_events_target_keydown(event: KeyboardEvent, mut ctx: ComboboxContext, open: Memo) { + if (ctx.selectable.disabled)() { + event.prevent_default(); + event.stop_propagation(); + return; + } + + match event.key() { + Key::ArrowDown => { + if !open() { + ctx.open_with_empty_query_and_focus_first(); + } else { + ctx.focus_next_visible(); + } + event.prevent_default(); + event.stop_propagation(); + } + Key::ArrowUp => { + if !open() { + ctx.open_with_empty_query_and_focus_last(); + } else { + ctx.focus_prev_visible(); + } + event.prevent_default(); + event.stop_propagation(); + } + Key::Home if open() => { + ctx.focus_first_visible(); + event.prevent_default(); + event.stop_propagation(); + } + Key::End if open() => { + ctx.focus_last_visible(); + event.prevent_default(); + event.stop_propagation(); + } + Key::Enter if open() => { + ctx.select_focused(); + event.prevent_default(); + event.stop_propagation(); + } + Key::Escape if open() => { + ctx.set_open(false); + event.prevent_default(); + event.stop_propagation(); + } + _ => {} + } +} + +/// Declarative props for the combobox focus target element. +#[derive(Clone, Copy)] +pub struct ComboboxTargetHandle { + ctx: ComboboxContext, +} + +impl ComboboxTargetHandle { + /// Returns attributes to spread onto the interactive target element. + pub fn spread(&self) -> Vec { + let ctx = self.ctx; + + attributes!(div { + "data-combobox-target": true, + onmounted: move |event| { + ctx.store.register_target_mount_ref(event.data()); + }, + }) + } + + /// Focuses the mounted target element. + pub fn focus(&self) { + self.ctx.store.focus_target(); + } +} + +/// Returns a handle for the combobox focus target element. +pub fn use_combobox_target() -> ComboboxTargetHandle { + ComboboxTargetHandle { + ctx: use_context::(), + } +} + +/// Returns attributes that register the current element as the combobox focus target. +/// +/// Prefer [`use_combobox_target`] for new code. +pub fn use_combobox_target_attributes() -> Vec { + use_combobox_target().spread() +} + +#[derive(Clone, Copy)] +struct ComboboxTargetWrapperHandle {} + +impl ComboboxTargetWrapperHandle { + fn spread(&self) -> Vec { + attributes!(div { + "data-combobox-target": true, + }) + } +} + +fn use_combobox_target_wrapper() -> ComboboxTargetWrapperHandle { + ComboboxTargetWrapperHandle {} +} + +/// Props for [`ComboboxTarget`]. +#[derive(Props, Clone, PartialEq)] +pub struct ComboboxTargetProps { + /// Optional custom element renderer for the target attributes. + #[props(default)] + pub r#as: Option, Element>>, + + /// Additional attributes. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Children rendered inside the target wrapper. + pub children: Element, +} + +/// Renders a structural wrapper around the combobox target area. +/// +/// Use [`use_combobox_target`] on a custom interactive element when +/// that element should receive [`ComboboxStore::focus_target`](crate::combobox::ComboboxStore::focus_target). +#[component] +pub fn ComboboxTarget(props: ComboboxTargetProps) -> Element { + let target = use_combobox_target(); + let wrapper = use_combobox_target_wrapper(); + + if let Some(dynamic) = props.r#as { + let merged = merge_attributes(vec![target.spread(), props.attributes]); + return dynamic.call(merged); + } + + let merged = merge_attributes(vec![wrapper.spread(), props.attributes]); + + rsx! { + div { + ..merged, + {props.children} + } + } +} + +/// Declarative props for the combobox element that owns trigger events. +#[derive(Clone, Copy)] +pub struct ComboboxEventsTargetHandle { + ctx: ComboboxContext, + open: Memo, + active_descendant: Memo>, + disabled: ReadSignal, +} + +impl ComboboxEventsTargetHandle { + /// Returns attributes to spread onto the combobox events target. + pub fn spread(&self) -> Vec { + let ctx = self.ctx; + let open = self.open; + let active_descendant = self.active_descendant; + let disabled = self.disabled; + + attributes!(div { + role: "combobox", + tabindex: if disabled() { "-1" } else { "0" }, + aria_haspopup: "listbox", + aria_expanded: open(), + aria_controls: ctx.selectable.list_id(), + aria_activedescendant: active_descendant(), + aria_disabled: disabled(), + "data-combobox-events-target": true, + "data-state": if open() { "open" } else { "closed" }, + "data-disabled": disabled(), + onclick: move |event| { + if disabled() { + event.prevent_default(); + event.stop_propagation(); + return; + } + if !open() { + ctx.set_query.call(String::new()); + } + ctx.store + .toggle_dropdown(ComboboxDropdownEventSource::Mouse); + }, + onkeydown: move |event| { + handle_events_target_keydown(event, ctx, open); + }, + }) + } + + /// Returns whether the dropdown is currently open. + pub fn opened(&self) -> bool { + (self.open)() + } +} + +/// Returns a handle for the combobox element that owns trigger events. +pub fn use_combobox_events_target() -> ComboboxEventsTargetHandle { + let ctx = use_context::(); + let open = use_memo(move || ctx.store.dropdown_opened()); + let active_descendant = active_descendant(ctx, open); + + ComboboxEventsTargetHandle { + ctx, + open, + active_descendant, + disabled: ctx.selectable.disabled, + } +} + +/// Returns attributes for the combobox events target. +/// +/// Prefer [`use_combobox_events_target`] for new code. +pub fn use_combobox_events_target_attributes() -> Vec { + use_combobox_events_target().spread() +} + +/// Props for [`ComboboxEventsTarget`]. +#[derive(Props, Clone, PartialEq)] +pub struct ComboboxEventsTargetProps { + /// Optional custom element renderer for the events target attributes. + #[props(default)] + pub r#as: Option, Element>>, + + /// Additional attributes. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Children rendered inside the events target. + pub children: Element, +} + +/// Element that owns combobox trigger ARIA and keyboard/pointer interactions. +#[component] +pub fn ComboboxEventsTarget(props: ComboboxEventsTargetProps) -> Element { + let target = use_combobox_events_target(); + let merged = merge_attributes(vec![target.spread(), props.attributes]); + + if let Some(dynamic) = props.r#as { + return dynamic.call(merged); + } + + rsx! { + div { + ..merged, + {props.children} + } + } +} + +/// Declarative props for the dropdown anchoring target. +#[derive(Clone, Copy)] +pub struct ComboboxDropdownTargetHandle { + open: Memo, +} + +impl ComboboxDropdownTargetHandle { + /// Returns attributes to spread onto the dropdown anchoring target. + pub fn spread(&self) -> Vec { + let open = self.open; + + attributes!(div { + "data-combobox-dropdown-target": true, + "data-state": if open() { "open" } else { "closed" }, + }) + } + + /// Returns whether the dropdown is currently open. + pub fn opened(&self) -> bool { + (self.open)() + } +} + +/// Returns a handle for the dropdown anchoring target. +pub fn use_combobox_dropdown_target() -> ComboboxDropdownTargetHandle { + let ctx = use_context::(); + let open = use_memo(move || ctx.store.dropdown_opened()); + + ComboboxDropdownTargetHandle { open } +} + +/// Returns attributes for an element that marks the dropdown anchoring target. +/// +/// Prefer [`use_combobox_dropdown_target`] for new code. +pub fn use_combobox_dropdown_target_attributes() -> Vec { + use_combobox_dropdown_target().spread() +} + +/// Props for [`ComboboxDropdownTarget`]. +#[derive(Props, Clone, PartialEq)] +pub struct ComboboxDropdownTargetProps { + /// Optional custom element renderer for the dropdown target attributes. + #[props(default)] + pub r#as: Option, Element>>, + + /// Additional attributes. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Children rendered inside the dropdown target. + pub children: Element, +} + +/// Wraps dropdown content when the dropdown target differs from the events target. +#[component] +pub fn ComboboxDropdownTarget(props: ComboboxDropdownTargetProps) -> Element { + let target = use_combobox_dropdown_target(); + let merged = merge_attributes(vec![target.spread(), props.attributes]); + + if let Some(dynamic) = props.r#as { + return dynamic.call(merged); + } + + rsx! { + div { + ..merged, + {props.children} + } + } +} + +/// Options for [`use_combobox_search`]. +#[derive(Clone, Copy)] +pub struct UseComboboxSearchOptions { + /// Placeholder shown when the input is empty. + pub placeholder: ReadSignal, + /// Optional id for the input element. + pub id: ReadSignal>, + /// Whether this input should also be the focus target. + pub register_target: bool, + /// Whether to show selected option text while the dropdown is closed. + pub show_selected_text: bool, +} + +impl Default for UseComboboxSearchOptions { + fn default() -> Self { + Self { + placeholder: ReadSignal::new(Signal::new(String::new())), + id: ReadSignal::new(Signal::new(None)), + register_target: false, + show_selected_text: true, + } + } +} + +/// Declarative props and controls for a native combobox search input. +#[derive(Clone, Copy)] +pub struct ComboboxSearchHandle { + ctx: ComboboxContext, + placeholder: ReadSignal, + id: ReadSignal, + register_target: bool, + open: Memo, + display_value: Memo, + active_descendant: Memo>, + disabled: ReadSignal, +} + +impl ComboboxSearchHandle { + /// Returns attributes to spread onto the search input. + pub fn spread(&self) -> Vec { + let mut ctx = self.ctx; + let placeholder = self.placeholder; + let id = self.id; + let register_target = self.register_target; + let open = self.open; + let set_query = ctx.set_query; + let display_value = self.display_value; + let active_descendant = self.active_descendant; + let disabled = self.disabled; + + attributes!(input { + id, + r#type: "text", + value: display_value(), + placeholder, + autocomplete: "off", + spellcheck: "false", + disabled: disabled(), + + role: "combobox", + aria_autocomplete: "list", + aria_haspopup: "listbox", + aria_expanded: open(), + aria_controls: ctx.selectable.list_id(), + aria_activedescendant: active_descendant(), + + "data-combobox-search": true, + "data-state": if open() { "open" } else { "closed" }, + + onclick: move |event| { + if disabled() { + event.prevent_default(); + event.stop_propagation(); + return; + } + if !open() { + set_query.call(String::new()); + ctx.set_open(true); + } + }, + oninput: move |event| { + if disabled() { + event.prevent_default(); + event.stop_propagation(); + return; + } + let was_open = open(); + let value = event.value(); + let next_query = if was_open { + value + } else { + ctx.selectable + .selected_text() + .and_then(|selected| { + value + .strip_prefix(&selected) + .map(ToString::to_string) + }) + .unwrap_or(value) + }; + set_query.call(next_query); + if was_open { + ctx.selectable.focus_state.set_focus(None); + ctx.store.reset_selected_option(); + } else { + ctx.set_open(true); + } + }, + onkeydown: move |event| { + handle_events_target_keydown(event, ctx, open); + }, + onmounted: move |event| { + if register_target { + ctx.store.register_target_mount_ref(event.data()); + } + ctx.store.register_search_mount_ref(event.data()); + }, + onblur: move |_| { + if open() { + ctx.set_open(false); + } + }, + }) + } + + /// Returns the current search query. + pub fn query(&self) -> String { + self.ctx.query.cloned() + } + + /// Updates the search query. + pub fn search_for(&self, query: impl Into) { + self.ctx.set_query.call(query.into()); + } + + /// Focuses the mounted search input. + pub fn focus(&self) { + self.ctx.store.focus_search_input(); + } + + /// Returns whether the dropdown is currently open. + pub fn opened(&self) -> bool { + (self.open)() + } +} + +/// Returns a handle for a native combobox search input. +pub fn use_combobox_search(options: UseComboboxSearchOptions) -> ComboboxSearchHandle { + let ctx = use_context::(); + let fallback_id = use_unique_id(); + let id = crate::use_id_or(fallback_id, options.id); + + let open = use_memo(move || ctx.store.dropdown_opened()); + let active_descendant = active_descendant(ctx, open); + let display_value = use_memo(move || { + if open() { + ctx.query.cloned() + } else if options.show_selected_text { + ctx.selectable.selected_text().unwrap_or_default() + } else { + String::new() + } + }); + + ComboboxSearchHandle { + ctx, + placeholder: options.placeholder, + id: id.into(), + register_target: options.register_target, + open, + display_value, + active_descendant, + disabled: ctx.selectable.disabled, + } +} + +/// Returns attributes for a native combobox search input. +/// +/// Prefer [`use_combobox_search`] for new code. +pub fn use_combobox_search_attributes( + placeholder: ReadSignal, + id: ReadSignal>, + register_target: bool, + show_selected_text: bool, +) -> Vec { + use_combobox_search(UseComboboxSearchOptions { + placeholder, + id, + register_target, + show_selected_text, + }) + .spread() +} + +pub(super) fn render_combobox_search( + placeholder: ReadSignal, + id: ReadSignal>, + attributes: Vec, + register_target: bool, + show_selected_text: bool, +) -> Element { + let search = use_combobox_search(UseComboboxSearchOptions { + placeholder, + id, + register_target, + show_selected_text, + }); + let merged = merge_attributes(vec![search.spread(), attributes]); + + rsx! { + input { + ..merged, + } + } +} diff --git a/primitives/src/combobox/components/virtualized.rs b/primitives/src/combobox/components/virtualized.rs new file mode 100644 index 00000000..159fe5be --- /dev/null +++ b/primitives/src/combobox/components/virtualized.rs @@ -0,0 +1,251 @@ +//! Virtualized combobox listbox component. + +use dioxus::prelude::*; + +use super::super::context::ComboboxContext; +use crate::listbox::{use_listbox_container_with_open, use_listbox_id}; + +/// Props for [`VirtualizedComboboxOptions`]. +#[derive(Props, Clone, PartialEq)] +pub struct VirtualizedComboboxOptionsProps { + /// The total number of options. + pub count: ReadSignal, + + /// Optional visible-row to absolute-option index mapping. + /// + /// When provided, the virtualizer only materializes the mapped rows and passes the underlying + /// absolute option index into [`Self::render_option`] and [`Self::estimate_size`]. + #[props(default)] + pub visible_indices: Option>>, + + /// The amount of render buffer in estimated row counts. + #[props(default = ReadSignal::new(Signal::new(8)))] + pub buffer: ReadSignal, + + /// Estimates the height of an option by absolute index. + pub estimate_size: Option>, + + /// Renders one option by absolute index. + pub render_option: Callback, + + /// Optional id for the listbox. + #[props(default)] + pub id: ReadSignal>, + + /// Additional attributes for the listbox scroll container. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} + +/// A virtualized combobox listbox that preserves listbox/option semantics. +#[component] +pub fn VirtualizedComboboxOptions(props: VirtualizedComboboxOptionsProps) -> Element { + let ctx = use_context::(); + let open = use_memo(move || ctx.store.dropdown_opened()); + let id = use_listbox_id(props.id, ctx.selectable.list_id); + let listbox = use_listbox_container_with_open(id, ctx.selectable, open); + let render = listbox.render; + + let mut scroll_offset = use_signal(|| 0u32); + let mut viewport_size = use_signal(|| 0u32); + + // The total number of visible rows (changes when the filter changes). + let visible_count = use_memo(move || { + props + .visible_indices + .as_ref() + .map(|indices| indices.read().len()) + .unwrap_or_else(|| (props.count)()) + }); + + // The single row-height estimate. We call estimate_size(0) as a representative sample. + // For comboboxes all options are the same height, so this is exact. + let est = use_memo(move || { + props + .estimate_size + .as_ref() + .map(|cb| { + let idx = props + .visible_indices + .as_ref() + .and_then(|s| s.read().first().copied()) + .unwrap_or(0); + cb(idx).max(1) + }) + .unwrap_or(36) + }); + + // Reset scroll position whenever the filter changes. + use_effect(move || { + let _ = visible_count.read(); + scroll_offset.set(0); + spawn(async move { + sync_scroll(listbox.id.peek().clone(), 0).await; + }); + }); + + // Read scroll position directly from the native scroll event — no JS eval loop needed. + // ScrollData carries scrollTop and clientHeight from the browser event itself. + let on_scroll = move |evt: Event| { + let data = evt.data(); + scroll_offset.set(data.scroll_top().round() as u32); + viewport_size.set(data.client_height() as u32); + }; + + // On mount, capture the initial viewport height so the window calculation is correct + // before the first scroll event fires. + let on_mounted = move |e: Event| { + let data = e.data(); + spawn(async move { + if let Ok(rect) = data.get_client_rect().await { + viewport_size.set(rect.size.height.round() as u32); + } + }); + // Ensure the signal state is clean for each fresh open. + scroll_offset.set(0); + }; + + // Scroll-to highlighted option using pure estimate positions. + use_effect(move || { + if !render() { + return; + } + let Some(highlighted_index) = ctx.store.highlighted_option_index() else { + return; + }; + let visible_index = if let Some(indices) = props.visible_indices.as_ref() { + let indices = indices.read(); + let Some(pos) = indices.iter().position(|&i| i == highlighted_index) else { + return; + }; + pos + } else { + highlighted_index + }; + let count = *visible_count.peek(); + if visible_index >= count { + return; + } + let e = *est.peek(); + let item_start = visible_index as u32 * e; + let item_end = item_start + e; + let current = *scroll_offset.peek(); + let vp = *viewport_size.peek(); + let next = if item_start < current { + Some(item_start) + } else if item_end > current.saturating_add(vp) { + Some(item_end.saturating_sub(vp)) + } else { + None + }; + if let Some(next) = next { + scroll_offset.set(next); + spawn(async move { + sync_scroll(listbox.id.peek().clone(), next).await; + }); + } + }); + + // ── Window calculation ──────────────────────────────────────────────────── + // + // The number of rendered DOM nodes MUST be stable during scroll. If it + // varies, Dioxus mounts/unmounts elements, which triggers browser layout + // recalculation and temporarily changes scrollHeight — making the thumb + // jump in size and position. + // + // Strategy (same as react-window / TanStack Virtual): + // 1. Compute `window_size` = rows_that_fit_in_viewport + 2 × buffer. + // This value is constant for a given viewport height. + // 2. Clamp `start` so that `start + window_size ≤ count`. This means + // near the end of the list we shift the window backward rather than + // letting it shrink — keeping the count fixed. + // 3. Each item is `position: absolute; transform: translateY(index * est)`. + // Items are NOT in normal document flow, so the canvas div's intrinsic + // height is zero — only the explicit `height: Xpx` CSS matters. + // `overflow: hidden` ensures no item can poke outside the canvas. + + let off = *scroll_offset.read(); + let vp = *viewport_size.read(); + let e = *est.read(); + let count = *visible_count.read(); + let buf = (props.buffer)(); + let e1 = e.max(1); + + // How many rows can the viewport hold? Use 240px as a stand-in before the + // first scroll event so the initial render is already fully populated. + let viewport_rows = if vp == 0 { 240 } else { vp }; + + // Fixed pool size — constant as long as viewport and buffer don't change. + let window_size = ((viewport_rows / e1) as usize + 2 * buf + 1).min(count); + + // Desired first visible row. + let desired_start = (off / e1).saturating_sub(buf as u32) as usize; + + // Clamp so we always emit exactly `window_size` items. At the bottom of + // the list this shifts the window backward instead of shrinking it. + let start = desired_start.min(count.saturating_sub(window_size)); + + // canvas_height = count × est. Fixed. Never changes during scroll. + let canvas_height = (count as u32 * e1).max(vp); + let set_size = count.to_string(); + + rsx! { + if render() { + div { + id: listbox.id, + role: "listbox", + "data-state": if open() { "open" } else { "closed" }, + onmounted: on_mounted, + onscroll: on_scroll, + onpointerdown: move |event| { + event.prevent_default(); + }, + ..props.attributes, + // Canvas: flex-shrink:0 is critical — the listbox is a flex column container, + // and without it the browser compresses this div to fit the max-height, + // eliminating overflow and making the list unscrollable. + div { style: "position: relative; overflow: hidden; flex-shrink: 0; height: {canvas_height}px; width: 100%;", + { + (start..start + window_size) + .map(move |visible_index| { + let index = props + .visible_indices + .as_ref() + .map(|indices| indices.read().get(visible_index).copied()) + .unwrap_or_else(|| { + (visible_index < count).then_some(visible_index) + }); + let item_top = visible_index as u32 * e1; + rsx! { + div { + key: "{visible_index}", + role: "presentation", + style: "position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item_top}px);", + "data-virtual-index": "{visible_index}", + "aria-setsize": "{set_size}", + "aria-posinset": "{visible_index + 1}", + {index.map(|i| (props.render_option)(i))} + } + } + }) + } + } + } + } else { + + } + } +} + +async fn sync_scroll(container_id: String, scroll_top: u32) { + let eval = document::eval( + r#" + const id = await dioxus.recv(); + const scrollTop = await dioxus.recv(); + const container = document.getElementById(id); + if (container) container.scrollTop = scrollTop; + "#, + ); + let _ = eval.send(container_id); + let _ = eval.send(scroll_top); +} diff --git a/primitives/src/combobox/context.rs b/primitives/src/combobox/context.rs index 052760ac..8f33abf2 100644 --- a/primitives/src/combobox/context.rs +++ b/primitives/src/combobox/context.rs @@ -1,5 +1,6 @@ //! Shared state for the combobox component. +use super::hook::{ComboboxDropdownEventSource, ComboboxIndexTarget, ComboboxStore}; use crate::selectable::{OptionState, SelectableContext}; use dioxus::prelude::*; @@ -12,17 +13,26 @@ pub fn default_combobox_filter(query: &str, text: &str) -> bool { #[derive(Clone, Copy)] pub(super) struct ComboboxContext { pub selectable: SelectableContext, + pub store: ComboboxStore, pub query: Memo, pub set_query: Callback, pub filter: Callback<(String, String), bool>, } impl ComboboxContext { + fn matches_query_text(&self, text: String) -> bool { + self.filter.call((self.query.cloned(), text)) + } + pub fn set_open(&mut self, open: bool) { if open { self.selectable.focus_state.set_focus(None); + self.store + .open_dropdown(ComboboxDropdownEventSource::Unknown); + } else { + self.store + .close_dropdown(ComboboxDropdownEventSource::Unknown); } - self.selectable.set_open(open); } fn predicate_for(&self, query: String) -> impl Fn(&OptionState) -> bool { @@ -34,14 +44,14 @@ impl ComboboxContext { self.predicate_for(self.query.cloned()) } - pub fn is_visible(&self, tab_index: usize) -> bool { + pub fn is_visible_text(&self, tab_index: usize, text: String) -> bool { let predicate = self.predicate(); self.selectable .options .read() .iter() .find(|option| option.tab_index == tab_index) - .is_some_and(predicate) + .map_or_else(|| self.matches_query_text(text), predicate) } pub fn has_visible_options(&self) -> bool { @@ -55,6 +65,9 @@ impl ComboboxContext { .selectable .first_matching_enabled_index(self.predicate_for(query)); self.selectable.initial_focus.set(initial_focus); + self.store.update_selected_option_index( + initial_focus.map_or(ComboboxIndexTarget::None, |_| ComboboxIndexTarget::First), + ); self.set_open(true); } @@ -65,30 +78,78 @@ impl ComboboxContext { .selectable .last_matching_enabled_index(self.predicate_for(query)); self.selectable.initial_focus.set(initial_focus); + self.store.update_selected_option_index( + initial_focus.map_or(ComboboxIndexTarget::None, |_| ComboboxIndexTarget::Last), + ); self.set_open(true); } pub fn focused_option_id(&self) -> Option { - self.selectable.focused_option_id() + self.store + .highlighted_option_index() + .and_then(|index| { + self.selectable + .options + .read() + .iter() + .find(|option| option.tab_index == index && !option.disabled) + .map(|option| option.id.clone()) + }) + .or_else(|| self.selectable.focused_option_id()) } pub fn focus_next_visible(&mut self) { self.selectable.focus_next_where(self.predicate()); + if let Some(index) = self.selectable.focus_state.current_focus() { + self.store.select_option(index); + } } pub fn focus_prev_visible(&mut self) { self.selectable.focus_prev_where(self.predicate()); + if let Some(index) = self.selectable.focus_state.current_focus() { + self.store.select_option(index); + } } pub fn focus_first_visible(&mut self) { self.selectable.focus_first_where(self.predicate()); + if let Some(index) = self.selectable.focus_state.current_focus() { + self.store.select_option(index); + } } pub fn focus_last_visible(&mut self) { self.selectable.focus_last_where(self.predicate()); + if let Some(index) = self.selectable.focus_state.current_focus() { + self.store.select_option(index); + } } pub fn select_focused(&mut self) { - self.selectable.select_focused(); + if let Some(index) = self.selectable.focus_state.current_focus() { + self.submit_index(index, ComboboxDropdownEventSource::Keyboard); + } + } + + pub fn submit_index(&mut self, index: usize, source: ComboboxDropdownEventSource) { + if self.store.select_option(index).is_none() { + return; + } + self.store.submit_selected_option(); + let Some(value) = self + .selectable + .options + .read() + .iter() + .find(|option| option.tab_index == index && !option.disabled) + .map(|option| option.value.clone()) + else { + return; + }; + self.selectable.select_value(value); + if !self.selectable.selection_mode.is_multiple() { + self.store.close_dropdown(source); + } } } diff --git a/primitives/src/combobox/hook.rs b/primitives/src/combobox/hook.rs new file mode 100644 index 00000000..5b90aaaf --- /dev/null +++ b/primitives/src/combobox/hook.rs @@ -0,0 +1,575 @@ +//! Value-agnostic combobox interaction store. + +use std::rc::Rc; + +use dioxus::prelude::*; + +use crate::disclosure::{use_disclosure, Disclosure, UseDisclosureOptions}; + +/// The user interaction source that requested a dropdown state change. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComboboxDropdownEventSource { + /// Keyboard interaction. + Keyboard, + /// Pointer or mouse interaction. + Mouse, + /// Programmatic or unknown interaction. + Unknown, +} + +/// Target used when updating the highlighted option index. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComboboxIndexTarget { + /// Clear the highlighted option. + None, + /// Highlight the first enabled and visible option. + First, + /// Highlight the last enabled and visible option. + Last, + /// Highlight the active option when one is registered. + Active, + /// Keep the current highlighted option if still enabled and visible. + Selected, +} + +/// Stable option key returned by combobox navigation methods. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ComboboxOptionKey { + /// DOM id registered by the option. + pub id: String, + /// Navigation index registered by the option. + pub index: usize, +} + +/// Metadata for an option submitted through the combobox store. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ComboboxSubmittedOption { + /// DOM id registered by the option. + pub id: String, + /// Navigation index registered by the option. + pub index: usize, +} + +/// Options for [`use_combobox`]. +#[derive(Clone, Copy)] +pub struct UseComboboxOptions { + /// Controlled dropdown open state. + pub opened: ReadSignal>, + /// Initial dropdown open state when uncontrolled. + pub default_opened: ReadSignal, + /// Callback fired when the dropdown open state changes. + pub on_opened_change: Callback, + /// Callback fired on closed-to-open dropdown transitions. + pub on_dropdown_open: Option>, + /// Callback fired on open-to-closed dropdown transitions. + pub on_dropdown_close: Option>, + /// Whether keyboard navigation wraps at list boundaries. + pub loop_navigation: ReadSignal, +} + +/// Options for [`use_virtualized_combobox`]. +#[derive(Clone, Copy, Default)] +pub struct UseVirtualizedComboboxOptions { + /// Base combobox interaction options. + pub combobox: UseComboboxOptions, +} + +/// Store returned by [`use_virtualized_combobox`]. +pub type VirtualizedComboboxStore = ComboboxStore; + +impl Default for UseComboboxOptions { + fn default() -> Self { + Self { + opened: ReadSignal::new(Signal::new(None)), + default_opened: ReadSignal::new(Signal::new(false)), + on_opened_change: Callback::default(), + on_dropdown_open: None, + on_dropdown_close: None, + loop_navigation: ReadSignal::new(Signal::new(true)), + } + } +} + +#[derive(Clone, PartialEq)] +struct ComboboxOptionState { + key: ComboboxOptionKey, + disabled: bool, + visible: bool, + active: bool, +} + +impl ComboboxOptionState { + fn enabled_visible(&self) -> bool { + !self.disabled && self.visible + } +} + +/// A cloneable combobox store handle. +#[derive(Clone, Copy)] +pub struct ComboboxStore { + disclosure: Disclosure, + options: Signal>, + highlighted_index: Signal>, + submitted_option: Signal>, + target_mount: Signal>>, + search_mount: Signal>>, + on_dropdown_open: Option>, + on_dropdown_close: Option>, + loop_navigation: ReadSignal, +} + +impl ComboboxStore { + /// Returns whether the dropdown is open. + pub fn dropdown_opened(&self) -> bool { + self.disclosure.opened() + } + + /// Opens the dropdown and reports the event source on transition. + pub fn open_dropdown(&self, source: ComboboxDropdownEventSource) { + if self.disclosure.open() { + if let Some(on_open) = self.on_dropdown_open { + on_open.call(source); + } + } + } + + /// Closes the dropdown and reports the event source on transition. + pub fn close_dropdown(&self, source: ComboboxDropdownEventSource) { + if self.disclosure.close() { + self.reset_selected_option(); + if let Some(on_close) = self.on_dropdown_close { + on_close.call(source); + } + } + } + + /// Toggles the dropdown and reports the event source on transition. + pub fn toggle_dropdown(&self, source: ComboboxDropdownEventSource) { + match self.disclosure.toggle() { + Some(true) => { + if let Some(on_open) = self.on_dropdown_open { + on_open.call(source); + } + } + Some(false) => { + self.reset_selected_option(); + if let Some(on_close) = self.on_dropdown_close { + on_close.call(source); + } + } + None => {} + } + } + + /// Returns the currently highlighted option index. + pub fn highlighted_option_index(&self) -> Option { + (self.highlighted_index)() + } + + /// Highlights the enabled, visible option with the given index. + pub fn select_option(&self, index: usize) -> Option { + let key = self + .options + .read() + .iter() + .find(|option| option.key.index == index && option.enabled_visible()) + .map(|option| option.key.clone())?; + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + Some(key) + } + + /// Highlights the first enabled and visible option. + pub fn select_first_option(&self) -> Option { + let key = first_enabled_visible(&self.options.read())?; + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + Some(key) + } + + /// Highlights the active option, or the first enabled visible option when no + /// active option is registered. + pub fn select_active_option(&self) -> Option { + let options = self.options.read(); + let key = options + .iter() + .find(|option| option.active && option.enabled_visible()) + .or_else(|| options.iter().find(|option| option.enabled_visible())) + .map(|option| option.key.clone())?; + drop(options); + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + Some(key) + } + + /// Highlights the next enabled and visible option. + pub fn select_next_option(&self) -> Option { + let key = next_enabled_visible( + &self.options.read(), + self.highlighted_option_index(), + (self.loop_navigation)(), + )?; + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + Some(key) + } + + /// Highlights the previous enabled and visible option. + pub fn select_previous_option(&self) -> Option { + let key = previous_enabled_visible( + &self.options.read(), + self.highlighted_option_index(), + (self.loop_navigation)(), + )?; + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + Some(key) + } + + /// Clears the highlighted option. + pub fn reset_selected_option(&self) { + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(None); + } + + /// Updates the highlighted option according to the requested target. + pub fn update_selected_option_index(&self, target: ComboboxIndexTarget) { + match target { + ComboboxIndexTarget::None => self.reset_selected_option(), + ComboboxIndexTarget::First => { + self.select_first_option(); + } + ComboboxIndexTarget::Last => { + if let Some(key) = last_enabled_visible(&self.options.read()) { + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + } + } + ComboboxIndexTarget::Active => { + self.select_active_option(); + } + ComboboxIndexTarget::Selected => { + if self + .highlighted_option_index() + .and_then(|index| self.select_option(index)) + .is_none() + { + self.reset_selected_option(); + } + } + } + } + + /// Returns the most recently submitted option metadata. + pub fn submitted_option(&self) -> Option { + (self.submitted_option)() + } + + /// Requests submission of the currently highlighted option. + pub fn submit_selected_option(&self) -> Option { + let index = self.highlighted_option_index()?; + let key = self.select_option(index)?; + let submitted = ComboboxSubmittedOption { + id: key.id, + index: key.index, + }; + let mut submitted_option = self.submitted_option; + submitted_option.set(Some(submitted.clone())); + Some(submitted) + } + + /// Focuses the element registered through + /// [`use_combobox_target`](crate::combobox::use_combobox_target) + /// when mounted. + pub fn focus_target(&self) { + focus_mounted(self.target_mount); + } + + /// Focuses the input registered through + /// [`use_combobox_search`](crate::combobox::use_combobox_search) + /// when mounted. + pub fn focus_search_input(&self) { + focus_mounted(self.search_mount); + } + + pub(crate) fn register_option( + &self, + id: String, + index: usize, + disabled: bool, + visible: bool, + active: bool, + ) { + let mut options = self.options; + sync_combobox_option( + &mut options.write(), + ComboboxOptionState { + key: ComboboxOptionKey { id, index }, + disabled, + visible, + active, + }, + ); + if self + .highlighted_option_index() + .is_some_and(|idx| !has_enabled_visible_index(&self.options.read(), idx)) + { + self.reset_selected_option(); + } + } + + pub(crate) fn unregister_option(&self, id: &str) { + let mut options = self.options; + options.write().retain(|option| option.key.id != id); + if self + .highlighted_option_index() + .is_some_and(|idx| !has_enabled_visible_index(&self.options.read(), idx)) + { + self.reset_selected_option(); + } + } + + pub(crate) fn register_target_mount_ref(&self, mounted: Rc) { + let mut target_mount = self.target_mount; + target_mount.set(Some(mounted)); + } + + pub(crate) fn register_search_mount_ref(&self, mounted: Rc) { + let mut search_mount = self.search_mount; + search_mount.set(Some(mounted)); + } +} + +/// Create a value-agnostic combobox interaction store. +pub fn use_combobox(options: UseComboboxOptions) -> ComboboxStore { + let disclosure = use_disclosure(UseDisclosureOptions { + opened: options.opened, + default_opened: options.default_opened, + on_opened_change: options.on_opened_change, + }); + let options_signal = use_signal(Vec::new); + let highlighted_index = use_signal(|| None); + let submitted_option = use_signal(|| None); + let target_mount = use_signal(|| None); + let search_mount = use_signal(|| None); + + ComboboxStore { + disclosure, + options: options_signal, + highlighted_index, + submitted_option, + target_mount, + search_mount, + on_dropdown_open: options.on_dropdown_open, + on_dropdown_close: options.on_dropdown_close, + loop_navigation: options.loop_navigation, + } +} + +/// Create a combobox store for virtualized listbox usage. +/// +/// Virtualization is handled by [`VirtualizedComboboxOptions`](crate::combobox::VirtualizedComboboxOptions); +/// this hook keeps the same value-agnostic interaction surface as [`use_combobox`]. +pub fn use_virtualized_combobox( + options: UseVirtualizedComboboxOptions, +) -> VirtualizedComboboxStore { + use_combobox(options.combobox) +} + +fn focus_mounted(mount: Signal>>) { + if let Some(md) = mount() { + spawn(async move { + let _ = md.set_focus(true).await; + }); + } +} + +fn sync_combobox_option(options: &mut Vec, option: ComboboxOptionState) { + if let Some(position) = options.iter().position(|item| item.key.id == option.key.id) { + if options[position].key.index == option.key.index { + options[position] = option; + } else { + options.remove(position); + insert_combobox_option(options, option); + } + } else { + insert_combobox_option(options, option); + } +} + +fn insert_combobox_option(options: &mut Vec, option: ComboboxOptionState) { + let insert_at = options.partition_point(|item| item.key.index <= option.key.index); + options.insert(insert_at, option); +} + +fn has_enabled_visible_index(options: &[ComboboxOptionState], index: usize) -> bool { + options + .iter() + .any(|option| option.key.index == index && option.enabled_visible()) +} + +fn first_enabled_visible(options: &[ComboboxOptionState]) -> Option { + options + .iter() + .find(|option| option.enabled_visible()) + .map(|option| option.key.clone()) +} + +fn last_enabled_visible(options: &[ComboboxOptionState]) -> Option { + options + .iter() + .rev() + .find(|option| option.enabled_visible()) + .map(|option| option.key.clone()) +} + +fn next_enabled_visible( + options: &[ComboboxOptionState], + current: Option, + loop_navigation: bool, +) -> Option { + match current { + Some(current) => options + .iter() + .find(|option| option.key.index > current && option.enabled_visible()) + .or_else(|| { + loop_navigation + .then(|| options.iter().find(|option| option.enabled_visible())) + .flatten() + }), + None => options.iter().find(|option| option.enabled_visible()), + } + .map(|option| option.key.clone()) +} + +fn previous_enabled_visible( + options: &[ComboboxOptionState], + current: Option, + loop_navigation: bool, +) -> Option { + match current { + Some(current) => options + .iter() + .rev() + .find(|option| option.key.index < current && option.enabled_visible()) + .or_else(|| { + loop_navigation + .then(|| options.iter().rev().find(|option| option.enabled_visible())) + .flatten() + }), + None if loop_navigation => options.iter().rev().find(|option| option.enabled_visible()), + None => options.iter().find(|option| option.enabled_visible()), + } + .map(|option| option.key.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn option(id: &str, index: usize) -> ComboboxOptionState { + ComboboxOptionState { + key: ComboboxOptionKey { + id: id.to_string(), + index, + }, + disabled: false, + visible: true, + active: false, + } + } + + #[test] + fn sync_combobox_option_keeps_index_order() { + let mut options = vec![option("a", 0), option("c", 2)]; + + sync_combobox_option(&mut options, option("b", 1)); + + let ids: Vec<_> = options + .iter() + .map(|option| option.key.id.as_str()) + .collect(); + assert_eq!(ids, ["a", "b", "c"]); + } + + #[test] + fn navigation_skips_disabled_and_invisible_options() { + let options = vec![ + option("a", 0), + ComboboxOptionState { + disabled: true, + ..option("b", 1) + }, + ComboboxOptionState { + visible: false, + ..option("c", 2) + }, + option("d", 3), + ]; + + assert_eq!( + next_enabled_visible(&options, Some(0), true).unwrap().id, + "d" + ); + assert_eq!( + previous_enabled_visible(&options, Some(3), true) + .unwrap() + .id, + "a" + ); + } + + #[test] + fn navigation_respects_loop_navigation_setting() { + let options = vec![option("a", 0), option("b", 1)]; + + assert_eq!( + next_enabled_visible(&options, Some(1), true).unwrap().id, + "a" + ); + assert!(next_enabled_visible(&options, Some(1), false).is_none()); + + assert_eq!( + previous_enabled_visible(&options, Some(0), true) + .unwrap() + .id, + "b" + ); + assert!(previous_enabled_visible(&options, Some(0), false).is_none()); + } + + #[test] + fn navigation_without_current_uses_first_or_last_consistently() { + let options = vec![option("a", 0), option("b", 1)]; + + assert_eq!(next_enabled_visible(&options, None, true).unwrap().id, "a"); + assert_eq!( + previous_enabled_visible(&options, None, true).unwrap().id, + "b" + ); + assert_eq!( + previous_enabled_visible(&options, None, false).unwrap().id, + "a" + ); + } + + #[test] + fn first_last_and_active_skip_unselectable_options() { + let options = vec![ + ComboboxOptionState { + disabled: true, + active: true, + ..option("a", 0) + }, + ComboboxOptionState { + visible: false, + active: true, + ..option("b", 1) + }, + option("c", 2), + ]; + + assert_eq!(first_enabled_visible(&options).unwrap().id, "c"); + assert_eq!(last_enabled_visible(&options).unwrap().id, "c"); + } +} diff --git a/primitives/src/combobox/mod.rs b/primitives/src/combobox/mod.rs index 7ee7e7b2..d4ee79be 100644 --- a/primitives/src/combobox/mod.rs +++ b/primitives/src/combobox/mod.rs @@ -1,15 +1,32 @@ //! Autocomplete input with a filterable popup list. //! //! `ComboboxInput` is the text input and trigger. `ComboboxList` contains -//! `ComboboxOption` children. +//! `ComboboxOption` children. Split anatomy is available through +//! `ComboboxTarget`, `ComboboxEventsTarget`, `ComboboxDropdownTarget`, +//! `ComboboxSearch`, and `ComboboxOptions`. mod components; mod context; +mod hook; pub use components::{ - Combobox, ComboboxEmpty, ComboboxEmptyProps, ComboboxInput, ComboboxInputProps, - ComboboxItemIndicator, ComboboxItemIndicatorProps, ComboboxList, ComboboxListProps, - ComboboxOption, ComboboxOptionProps, ComboboxProps, + use_combobox_dropdown_target, use_combobox_dropdown_target_attributes, + use_combobox_events_target, use_combobox_events_target_attributes, use_combobox_search, + use_combobox_search_attributes, use_combobox_target, use_combobox_target_attributes, + Autocomplete, AutocompleteProps, Combobox, ComboboxDropdownTarget, + ComboboxDropdownTargetHandle, ComboboxDropdownTargetProps, ComboboxEmpty, ComboboxEmptyProps, + ComboboxEventsTarget, ComboboxEventsTargetHandle, ComboboxEventsTargetProps, ComboboxInput, + ComboboxInputProps, ComboboxItemIndicator, ComboboxItemIndicatorProps, ComboboxList, + ComboboxListProps, ComboboxOption, ComboboxOptionProps, ComboboxOptions, ComboboxOptionsProps, + ComboboxProps, ComboboxSearch, ComboboxSearchHandle, ComboboxSearchProps, ComboboxTarget, + ComboboxTargetHandle, ComboboxTargetProps, MultiSelect, MultiSelectProps, Pill, PillProps, + PillsInput, PillsInputProps, TagsInput, TagsInputProps, UseComboboxSearchOptions, + VirtualizedComboboxOptions, VirtualizedComboboxOptionsProps, }; pub use context::default_combobox_filter; +pub use hook::{ + use_combobox, use_virtualized_combobox, ComboboxDropdownEventSource, ComboboxIndexTarget, + ComboboxOptionKey, ComboboxStore, ComboboxSubmittedOption, UseComboboxOptions, + UseVirtualizedComboboxOptions, VirtualizedComboboxStore, +}; diff --git a/primitives/src/disclosure.rs b/primitives/src/disclosure.rs new file mode 100644 index 00000000..6d5af0ad --- /dev/null +++ b/primitives/src/disclosure.rs @@ -0,0 +1,90 @@ +//! Controlled or uncontrolled open/closed state. + +use dioxus::prelude::*; + +use crate::use_controlled; + +/// Options for [`use_disclosure`]. +#[derive(Clone, Copy)] +pub struct UseDisclosureOptions { + /// Controlled open state. When set, the returned state follows this value. + pub opened: ReadSignal>, + /// Initial open state when uncontrolled. + pub default_opened: ReadSignal, + /// Callback fired when the open state changes. + pub on_opened_change: Callback, +} + +impl Default for UseDisclosureOptions { + fn default() -> Self { + Self { + opened: ReadSignal::new(Signal::new(None)), + default_opened: ReadSignal::new(Signal::new(false)), + on_opened_change: Callback::default(), + } + } +} + +/// A cloneable handle for disclosure state. +#[derive(Clone, Copy)] +pub struct Disclosure { + opened: Memo, + set_opened: Callback, +} + +impl Disclosure { + /// Returns whether the disclosure is currently open. + pub fn opened(&self) -> bool { + (self.opened)() + } + + /// Opens the disclosure. + /// + /// Returns `true` when this call requested an actual closed-to-open + /// transition. + pub fn open(&self) -> bool { + self.set_opened_if_changed(true) + } + + /// Closes the disclosure. + /// + /// Returns `true` when this call requested an actual open-to-closed + /// transition. + pub fn close(&self) -> bool { + self.set_opened_if_changed(false) + } + + /// Toggles the disclosure. + /// + /// Returns the next open state when this call requested a transition. + pub fn toggle(&self) -> Option { + let next = !self.opened(); + self.set_opened_if_changed(next).then_some(next) + } + + /// Sets the disclosure open state. + /// + /// Returns `true` when the requested value differs from the current value. + pub fn set_opened(&self, opened: bool) -> bool { + self.set_opened_if_changed(opened) + } + + fn set_opened_if_changed(&self, opened: bool) -> bool { + if self.opened() == opened { + return false; + } + self.set_opened.call(opened); + true + } +} + +/// Create controlled or uncontrolled disclosure state with transition helpers. +pub fn use_disclosure(options: UseDisclosureOptions) -> Disclosure { + let (opened, set_opened) = use_controlled( + options.opened, + options.default_opened.cloned(), + options.on_opened_change, + ); + + Disclosure { opened, set_opened } +} diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index facfc1e6..60327833 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -25,6 +25,7 @@ pub mod combobox; pub mod context_menu; pub mod date_picker; pub mod dialog; +pub mod disclosure; pub mod drag_and_drop_list; pub mod dropdown_menu; mod focus; diff --git a/primitives/src/listbox.rs b/primitives/src/listbox.rs index 6ef68db4..a6db3051 100644 --- a/primitives/src/listbox.rs +++ b/primitives/src/listbox.rs @@ -47,10 +47,18 @@ pub(crate) fn use_listbox_render( pub(crate) fn use_listbox_container( id: ReadSignal>, - mut selectable: SelectableContext, + selectable: SelectableContext, ) -> ListboxState { let id = use_listbox_id(id, selectable.list_id); - let render = use_listbox_render(id, selectable.open); + use_listbox_container_with_open(id, selectable, selectable.open) +} + +pub(crate) fn use_listbox_container_with_open( + id: Memo, + mut selectable: SelectableContext, + open: impl Readable + Copy + 'static, +) -> ListboxState { + let render = use_listbox_render(id, open); use_context_provider(|| ListboxContext { render: render.into(), diff --git a/primitives/src/selectable.rs b/primitives/src/selectable.rs index 51eecf5c..f73fb7f4 100644 --- a/primitives/src/selectable.rs +++ b/primitives/src/selectable.rs @@ -80,6 +80,10 @@ impl SelectableContext { selection::selected_text(values.iter(), &options) } + pub(crate) fn list_id(&self) -> Option { + (self.list_id)() + } + pub(crate) fn is_selected(&self, value: &RcPartialEqValue) -> bool { self.values.read().iter().any(|selected| selected == value) } @@ -209,11 +213,72 @@ pub(crate) fn use_selectable_root( open: Controlled, ) -> SelectableContext { let (open, set_open) = use_controlled(open.value, open.default.cloned(), open.on_change); - let options: Signal> = use_signal(Vec::default); - let list_id = use_signal(|| None); let focus_state = use_focus_provider(roving_loop); let initial_focus = use_signal(|| None); + build_selectable_context( + SelectableRootState { + open, + set_open, + focus_state, + initial_focus, + }, + values, + set_value, + selection_mode, + disabled, + ) +} + +pub(crate) fn use_selectable_root_with_state( + values: Memo>, + set_value: Callback, + selection_mode: SelectionMode, + disabled: ReadSignal, + roving_loop: ReadSignal, + open: Memo, + set_open: Callback, +) -> SelectableContext { + let focus_state = use_focus_provider(roving_loop); + let initial_focus = use_signal(|| None); + + build_selectable_context( + SelectableRootState { + open, + set_open, + focus_state, + initial_focus, + }, + values, + set_value, + selection_mode, + disabled, + ) +} + +struct SelectableRootState { + open: Memo, + set_open: Callback, + focus_state: FocusState, + initial_focus: Signal>, +} + +fn build_selectable_context( + state: SelectableRootState, + values: Memo>, + set_value: Callback, + selection_mode: SelectionMode, + disabled: ReadSignal, +) -> SelectableContext { + let options: Signal> = use_signal(Vec::default); + let list_id = use_signal(|| None); + let SelectableRootState { + open, + set_open, + focus_state, + initial_focus, + } = state; + SelectableContext { open, set_open, diff --git a/primitives/src/virtual/mod.rs b/primitives/src/virtual/mod.rs index a0be72dc..b0c207b6 100644 --- a/primitives/src/virtual/mod.rs +++ b/primitives/src/virtual/mod.rs @@ -1,16 +1,20 @@ -//! Virtual list implementation using Dioxus Store for fine-grained reactivity. +//! Low-level virtualizer primitives using Dioxus Store for fine-grained reactivity. //! //! This module provides the core algorithms needed for efficient list virtualization: //! //! - Computing item positions from measured or estimated sizes //! - Calculating the visible range using binary search //! - Handling scroll position corrections when items resize +//! +//! These APIs intentionally do not render any accessibility roles, keyboard behavior, focus +//! management, or scroll container DOM. Higher-level components such as virtualized listboxes must +//! provide those semantics themselves. -pub(crate) mod types; +pub mod types; mod utils; mod virtualizer; -pub(crate) use virtualizer::{ +pub use virtualizer::{ compute_measurements, get_total_size, get_virtual_items, resize_item, set_scroll_offset, set_viewport_size, VirtualizerState, VirtualizerStateStoreExt, }; diff --git a/primitives/src/virtual/types.rs b/primitives/src/virtual/types.rs index 52e26312..444b8035 100644 --- a/primitives/src/virtual/types.rs +++ b/primitives/src/virtual/types.rs @@ -1,11 +1,11 @@ //! Core types for the virtual list implementation. /// A unique key for identifying items in the virtualizer. -pub(crate) type Key = usize; +pub type Key = usize; /// A single virtualized item with computed position. #[derive(Debug, Clone, PartialEq)] -pub(crate) struct VirtualItem { +pub struct VirtualItem { key: Key, index: usize, start: u32, @@ -13,7 +13,8 @@ pub(crate) struct VirtualItem { } impl VirtualItem { - pub(crate) fn new(key: Key, index: usize, start: u32, size: u32) -> Self { + /// Create a new virtual item measurement. + pub fn new(key: Key, index: usize, start: u32, size: u32) -> Self { Self { key, index, @@ -22,23 +23,28 @@ impl VirtualItem { } } - pub(crate) fn key(&self) -> Key { + /// Return the stable key used for size caching. + pub fn key(&self) -> Key { self.key } - pub(crate) fn index(&self) -> usize { + /// Return the absolute item index. + pub fn index(&self) -> usize { self.index } - pub(crate) fn start(&self) -> u32 { + /// Return the item start offset in pixels. + pub fn start(&self) -> u32 { self.start } - pub(crate) fn end(&self) -> u32 { + /// Return the item end offset in pixels. + pub fn end(&self) -> u32 { self.start + self.size } - pub(crate) fn size(&self) -> u32 { + /// Return the item size in pixels. + pub fn size(&self) -> u32 { self.size } } diff --git a/primitives/src/virtual/virtualizer.rs b/primitives/src/virtual/virtualizer.rs index 89bc31be..7ea4b6c2 100644 --- a/primitives/src/virtual/virtualizer.rs +++ b/primitives/src/virtual/virtualizer.rs @@ -1,4 +1,5 @@ //! Core Virtualizer implementation using Dioxus Store for fine-grained reactivity. +#![allow(missing_docs)] use std::collections::HashMap; use std::ops::Range; @@ -37,6 +38,28 @@ pub struct VirtualizerState { pub deferred_adjustments: i32, } +impl VirtualizerState { + /// Create an empty virtualizer state. + pub fn new() -> Self { + Self { + scroll_offset: 0, + viewport_size: 0, + is_scrolling: false, + item_size_cache: HashMap::new(), + scroll_adjustments: 0, + stable_total_size: None, + stable_measurement_count: None, + deferred_adjustments: 0, + } + } +} + +impl Default for VirtualizerState { + fn default() -> Self { + Self::new() + } +} + // --------------------------------------------------------------------------- // Public API – free functions operating on Store // --------------------------------------------------------------------------- diff --git a/primitives/src/virtual_list.rs b/primitives/src/virtual_list.rs index 3a613629..de006598 100644 --- a/primitives/src/virtual_list.rs +++ b/primitives/src/virtual_list.rs @@ -1,7 +1,5 @@ //! Defines the [`VirtualList`] component for rendering large lists with virtualization. -use std::collections::HashMap; - use dioxus::prelude::*; use serde::Deserialize; @@ -84,16 +82,7 @@ pub fn VirtualList(props: VirtualListProps) -> Element { let container_id = crate::use_unique_id(); // Create the Store — only holds mutable shared state - let state: Store = use_store(|| VirtualizerState { - scroll_offset: 0, - viewport_size: 0, - is_scrolling: false, - item_size_cache: HashMap::new(), - scroll_adjustments: 0, - stable_total_size: None, - stable_measurement_count: None, - deferred_adjustments: 0, - }); + let state: Store = use_store(VirtualizerState::new); // Measurements as a memo — recomputes when count or item_size_cache change. // Read (not peeked) by the render body so the component re-renders when the