diff --git a/component.json b/component.json index c0986114..70312c76 100644 --- a/component.json +++ b/component.json @@ -35,6 +35,7 @@ "preview/src/components/aspect_ratio", "preview/src/components/scroll_area", "preview/src/components/date_picker", + "preview/src/components/time_picker", "preview/src/components/textarea", "preview/src/components/skeleton", "preview/src/components/card", 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/playwright/schedule.spec.ts b/playwright/schedule.spec.ts new file mode 100644 index 00000000..b382baeb --- /dev/null +++ b/playwright/schedule.spec.ts @@ -0,0 +1,525 @@ +import { test, expect, type Locator, type Page } from "@playwright/test"; + +const BASE = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:4173"; +const LOAD_TIMEOUT = 20 * 60 * 1000; +const ROOT_SELECTOR = "[data-schedule-root]:visible"; + +const mainUrl = `${BASE}/component/?name=schedule&`; +const variantUrl = (variant: string) => + `${BASE}/component/block/?name=schedule&variant=${variant}&`; + +async function loadMain(page: Page) { + return await loadSchedulePage(page, mainUrl); +} + +async function loadVariant(page: Page, variant: string) { + return await loadSchedulePage(page, variantUrl(variant)); +} + +async function loadSchedulePage(page: Page, url: string) { + const root = page.locator(ROOT_SELECTOR).first(); + const deadline = Date.now() + LOAD_TIMEOUT; + let lastBodyText = ""; + + while (Date.now() < deadline) { + await page.goto(url, { timeout: LOAD_TIMEOUT, waitUntil: "commit" }); + + try { + await expect(root).toBeVisible({ timeout: 5_000 }); + return root; + } catch { + lastBodyText = await page.locator("body").innerText().catch(() => ""); + await page.waitForTimeout(2_000); + } + } + + throw new Error( + `Timed out waiting for a visible schedule root at ${url}. Last body text: ${lastBodyText.slice(0, 400)}`, + ); +} + +function header(root: Locator) { + return root.locator("[data-schedule-header]").first(); +} + +function viewButton(root: Locator, view: "day" | "week" | "month" | "year") { + return root + .getByRole("button", { name: new RegExp(`^${view}$`, "i") }) + .first(); +} + +function visibleEvent(root: Locator, name: string) { + return root + .locator("[data-schedule-event]:not([data-schedule-resize-preview])") + .filter({ hasText: name }) + .first(); +} + +function eventsByName(root: Locator, name: string) { + return root + .locator("[data-schedule-event]:not([data-schedule-resize-preview])") + .filter({ hasText: name }); +} + +async function timeSlotHeights(root: Locator) { + return await root + .locator("[data-schedule-time-slot]") + .evaluateAll((slots) => + slots.slice(0, 28).map((slot) => slot.getBoundingClientRect().height), + ); +} + +async function resizeEventEnd( + page: Page, + root: Locator, + event: Locator, + targetSlot: Locator, +) { + const handle = event.locator("[data-schedule-resize-handle='end']").first(); + const beforeHeights = await timeSlotHeights(root); + + await event.hover(); + await expect(handle).toBeVisible(); + + const handleBox = await handle.boundingBox(); + const targetBox = await targetSlot.boundingBox(); + + expect(handleBox).not.toBeNull(); + expect(targetBox).not.toBeNull(); + + await page.mouse.move( + handleBox!.x + handleBox!.width / 2, + handleBox!.y + handleBox!.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + targetBox!.x + targetBox!.width / 2, + targetBox!.y + targetBox!.height / 2, + { steps: 8 }, + ); + await expect(root).toHaveAttribute("data-resizing", "true"); + await expect(root).toHaveAttribute("data-dragging", "false"); + await expect(event).toHaveAttribute("data-draggable", "false"); + await expect(event).toHaveCSS("visibility", "hidden"); + + const afterHeights = await timeSlotHeights(root); + expect(afterHeights).toEqual(beforeHeights); +} + +async function tabUntilFocused(page: Page, locator: Locator, attempts = 12) { + for (let index = 0; index < attempts; index += 1) { + await page.keyboard.press("Tab"); + if (await locator.evaluate((element) => element === document.activeElement)) { + return; + } + } + + throw new Error(`Failed to focus locator after ${attempts} Tab presses`); +} + +async function dragAcrossSlots(page: Page, startSlot: Locator, endSlot: Locator) { + const startBox = await startSlot.boundingBox(); + const endBox = await endSlot.boundingBox(); + + expect(startBox).not.toBeNull(); + expect(endBox).not.toBeNull(); + + await page.mouse.move( + startBox!.x + startBox!.width / 2, + startBox!.y + startBox!.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + endBox!.x + endBox!.width / 2, + endBox!.y + endBox!.height / 2, + { steps: 8 }, + ); +} + +test("preview page loads with header, controls, and events", async ({ + page, +}) => { + const root = await loadMain(page); + + await expect(root).toHaveAttribute("data-view", "week"); + await expect(root).toHaveAttribute("data-mode", "default"); + await expect(root).toHaveAttribute("data-layout", "default"); + await expect(root).toHaveAttribute("data-locale", "en-US"); + await expect(header(root)).toBeVisible(); + await expect(root.getByRole("button", { name: "Previous" })).toBeVisible(); + await expect(root.getByRole("button", { name: "Today" })).toBeVisible(); + await expect(root.getByRole("button", { name: "Next" })).toBeVisible(); + await expect( + root.getByRole("navigation", { name: "Schedule views" }), + ).toBeVisible(); + await expect(viewButton(root, "day")).toHaveAttribute("data-active", "false"); + await expect(viewButton(root, "week")).toHaveAttribute("data-active", "true"); + await expect(viewButton(root, "month")).toHaveAttribute("data-active", "false"); + await expect(viewButton(root, "year")).toHaveAttribute("data-active", "false"); + await expect(root.locator("[data-schedule-desktop]")).toBeVisible(); + await expect(visibleEvent(root, "Launch window")).toBeVisible(); + await expect(visibleEvent(root, "Team onsite")).toHaveAttribute( + "data-all-day", + "true", + ); + const resizeHandle = visibleEvent(root, "Launch window") + .locator("[data-schedule-resize-handle='end']") + .first(); + await expect(resizeHandle).toBeHidden(); + await visibleEvent(root, "Launch window").hover(); + await expect(resizeHandle).toBeVisible(); + + const firstSlot = root.locator("[data-schedule-time-slot]").first(); + await visibleEvent(root, "Launch window").dragTo(firstSlot); + await expect(page.locator("[data-schedule-main-status]")).toContainText( + "Dropped Launch window", + ); + await expect(root).toHaveAttribute("data-dragging", "false"); + + await visibleEvent(root, "Launch window").click(); + await expect(page.locator("[data-schedule-main-status]")).toContainText( + "Clicked event Launch window", + ); + + await root.locator("[data-schedule-time-slot]").nth(1).click(); + await expect(page.locator("[data-schedule-main-status]")).toContainText( + "Clicked time slot", + ); + + await root.locator("[data-schedule-all-day-slot]").first().click(); + await expect(page.locator("[data-schedule-main-status]")).toContainText( + "Clicked all-day slot", + ); +}); + +test("week view renders day headers above the all-day lane and timed grid", async ({ + page, +}) => { + const root = await loadMain(page); + const allDayRow = root.locator("[data-schedule-all-day-row]"); + const allDaySlots = allDayRow.locator("[data-schedule-all-day-slot]"); + const firstDayHeader = root.locator("[data-schedule-day-header]").first(); + const firstAllDaySlot = allDaySlots.first(); + const firstTimeSlot = root.locator("[data-schedule-time-slot]").first(); + const allDayEvent = visibleEvent(root, "Team onsite"); + const timedEvent = visibleEvent(root, "Launch window"); + const visibleAllDayLabel = allDayRow.getByText("All day", { exact: true }); + + const [dayHeaderBox, allDaySlotBox, timeSlotBox] = await Promise.all([ + firstDayHeader.boundingBox(), + firstAllDaySlot.boundingBox(), + firstTimeSlot.boundingBox(), + ]); + + expect(dayHeaderBox).not.toBeNull(); + expect(allDaySlotBox).not.toBeNull(); + expect(timeSlotBox).not.toBeNull(); + expect(dayHeaderBox!.y).toBeLessThan(allDaySlotBox!.y); + expect(allDaySlotBox!.y).toBeLessThan(timeSlotBox!.y); + await expect(firstAllDaySlot).toContainText("All day"); + await expect(allDaySlots).toHaveCount(7); + await expect(allDaySlots.first()).toHaveAttribute("aria-label", /All day .+/); + await expect(allDaySlots.nth(1)).toHaveAttribute("aria-label", /All day .+/); + const slotTexts = await allDaySlots.evaluateAll((elements) => + elements.map((element) => element.textContent?.trim() ?? ""), + ); + expect(slotTexts.filter((text) => text === "All day")).toHaveLength(1); + expect(slotTexts.indexOf("All day")).toBe(0); + + await expect( + allDayEvent.locator("xpath=ancestor::*[@data-schedule-all-day-events][1]"), + ).toBeVisible(); + await expect( + timedEvent.locator("xpath=ancestor::*[@data-schedule-time-slot][1]"), + ).toBeVisible(); +}); + +test("keyboard reaches the primary navigation controls", async ({ page }) => { + const root = await loadMain(page); + const prev = root.getByRole("button", { name: "Previous" }); + const next = root.getByRole("button", { name: "Next" }); + const today = root.getByRole("button", { name: "Today" }); + const day = viewButton(root, "day"); + const week = viewButton(root, "week"); + + await page.locator("body").click(); + await tabUntilFocused(page, prev); + await expect(prev).toBeFocused(); + + await tabUntilFocused(page, next); + await expect(next).toBeFocused(); + + await tabUntilFocused(page, today); + await expect(today).toBeFocused(); + + await tabUntilFocused(page, day); + await expect(day).toBeFocused(); + + await tabUntilFocused(page, week); + await expect(week).toBeFocused(); +}); + +test("view switching, date navigation, and year-to-month transition work", async ({ + page, +}) => { + const root = await loadMain(page); + const datePicker = root.locator("[data-schedule-date-picker]"); + await expect(datePicker).toContainText("2026"); + await expect(datePicker).toContainText("05"); + await expect(datePicker).toContainText("18"); + + await viewButton(root, "day").click(); + await expect(root).toHaveAttribute("data-view", "day"); + await expect(root.locator("[data-schedule-view='day']")).toBeVisible(); + + await viewButton(root, "week").click(); + await expect(root).toHaveAttribute("data-view", "week"); + await expect(root.locator("[data-schedule-view='week']")).toBeVisible(); + + await root.getByRole("button", { name: "Next" }).click(); + await expect(datePicker).toContainText("25"); + await expect(eventsByName(root, "Launch window")).toHaveCount(0); + + await root.getByRole("button", { name: "Previous" }).click(); + await expect(datePicker).toContainText("18"); + await expect(visibleEvent(root, "Launch window")).toBeVisible(); + + await viewButton(root, "year").click(); + await expect(root).toHaveAttribute("data-view", "year"); + await expect(root.locator("[data-schedule-view='year']")).toBeVisible(); + + await root.locator("[data-schedule-year-month='10']").click(); + await expect(root).toHaveAttribute("data-view", "month"); + await expect(root.locator("[data-schedule-view='month']")).toBeVisible(); + await expect(datePicker).toContainText("10"); +}); + +test("time slots, all-day slots, and drag-selection signals are visible", async ({ + page, +}) => { + const root = await loadVariant(page, "slot_selection"); + const selected = root.locator("xpath=preceding-sibling::div[1]"); + const firstSlot = root.locator("[data-schedule-time-slot]").nth(0); + const secondSlot = root.locator("[data-schedule-time-slot]").nth(1); + const thirdSlot = root.locator("[data-schedule-time-slot]").nth(2); + const allDaySlot = root.locator("[data-schedule-all-day-slot]").first(); + + await expect(firstSlot).toBeVisible(); + await expect(firstSlot).toHaveAttribute("data-slot-select-enabled", "true"); + await expect(allDaySlot).toBeVisible(); + + await firstSlot.click(); + await expect(selected).toContainText("Created"); + + await dragAcrossSlots(page, secondSlot, thirdSlot); + await expect( + root.locator("[data-schedule-time-slot][data-selected-range='true']"), + ).toHaveCount(2); + await page.mouse.up(); + await expect(selected).toContainText("Created"); + await expect(selected).toContainText("to"); + + await allDaySlot.click(); + await expect(allDaySlot).toBeVisible(); +}); + +test("event drag/drop and resize callbacks are reflected in the preview", async ({ + page, +}) => { + const dragRoot = await loadVariant(page, "drag_and_drop"); + const draggableEvent = visibleEvent(dragRoot, "Launch window"); + const dropTarget = dragRoot.locator("[data-schedule-time-slot]").first(); + const allDayTarget = dragRoot.locator("[data-schedule-all-day-slot]").first(); + const allDayEvents = dragRoot.locator("[data-schedule-all-day-events]").first(); + + await expect(draggableEvent).toHaveAttribute("data-draggable", "true"); + await expect(draggableEvent).toHaveAttribute("data-resizable", "false"); + + await draggableEvent.dragTo(dropTarget); + await expect(page.getByText("Dropped Launch window")).toBeVisible(); + await expect(dropTarget).toContainText("Launch window"); + await expect(allDayEvents).not.toContainText("Launch window"); + + await dropTarget + .locator("[data-schedule-event]") + .filter({ hasText: "Launch window" }) + .first() + .dragTo(allDayTarget); + await expect(page.getByText("Dropped Launch window")).toBeVisible(); + await expect(allDayEvents).toContainText("Launch window"); + await expect( + allDayEvents + .locator("[data-schedule-event]") + .filter({ hasText: "Launch window" }) + .first(), + ).toHaveAttribute("data-all-day", "true"); + await expect(dropTarget).not.toContainText("Launch window"); + + await allDayEvents + .locator("[data-schedule-event]") + .filter({ hasText: "Launch window" }) + .first() + .dragTo(dropTarget); + await expect(page.getByText("Dropped Launch window")).toBeVisible(); + await expect( + dropTarget + .locator("[data-schedule-event]") + .filter({ hasText: "Launch window" }) + .first(), + ).toHaveAttribute( + "data-all-day", + "false", + ); + await expect(dropTarget).toContainText("Launch window"); + await expect(allDayEvents).not.toContainText("Launch window"); + + const resizeRoot = await loadVariant(page, "resize"); + const resizeHandle = resizeRoot + .locator("[data-schedule-resize-handle='end']") + .first(); + const startResizeHandle = resizeRoot + .locator("[data-schedule-resize-handle='start']") + .first(); + const resizableEvent = visibleEvent(resizeRoot, "Launch window"); + const laterSlot = resizeRoot.locator("[data-schedule-time-slot]").nth(5); + + await expect(resizableEvent).toHaveAttribute("data-resizable", "true"); + await expect(resizeHandle).toBeHidden(); + await expect(startResizeHandle).toBeHidden(); + await resizableEvent.hover(); + await expect(resizeHandle).toBeVisible(); + await expect(startResizeHandle).toBeVisible(); + await expect(startResizeHandle).toHaveCSS("top", "2px"); + await expect(resizeHandle).toHaveCSS("bottom", "2px"); + await resizeEventEnd(page, resizeRoot, resizableEvent, laterSlot); + await expect(resizeRoot.locator("[data-schedule-resize-preview]")).toBeVisible(); + await page.mouse.up(); + await expect(page.getByText("Resized Launch window")).toBeVisible(); + await expect(page.getByText("Resized Launch window")).not.toContainText( + "10:30", + ); +}); + +test("dragging one recurring occurrence detaches only that occurrence", async ({ + page, +}) => { + const root = await loadMain(page); + const recurringEvents = root + .locator("[data-schedule-event]") + .filter({ hasText: "Daily team sync" }); + const targetSlot = root.locator("[data-schedule-time-slot]").nth(0); + const originalTimeEvents = recurringEvents.filter({ + hasText: "9 AM - 9:30 AM", + }); + const movedTimeEvents = recurringEvents.filter({ hasText: "7 AM - 7:30 AM" }); + const initialOriginalTimeCount = await originalTimeEvents.count(); + const initialMovedTimeCount = await movedTimeEvents.count(); + + await expect + .poll(async () => recurringEvents.count()) + .toBeGreaterThan(1); + await expect(initialOriginalTimeCount).toBeGreaterThan(1); + + await recurringEvents.first().dragTo(targetSlot); + await expect(page.locator("[data-schedule-main-status]")).toContainText( + "Dropped Daily team sync", + ); + await expect + .poll(async () => recurringEvents.count()) + .toBeGreaterThan(1); + await expect(originalTimeEvents).toHaveCount(initialOriginalTimeCount - 1); + await expect(movedTimeEvents).toHaveCount(initialMovedTimeCount + 1); +}); + +test("external drops expose external data in the preview", async ({ page }) => { + const root = await loadVariant(page, "external_drop"); + const source = page.locator("[data-schedule-external-source]"); + const target = root.locator("[data-schedule-time-slot]").first(); + const message = page.locator("[data-schedule-external-drop-status]"); + + await expect(source).toBeVisible(); + await source.dragTo(target); + await expect(message).toContainText("Dropped"); + await expect(message).toContainText("External planning task"); +}); + +test("controlled state, custom event rendering, and recurrence are observable", async ({ + page, +}) => { + const controlledRoot = await loadVariant(page, "controlled"); + await viewButton(controlledRoot, "month").click(); + await expect(page.locator("[data-schedule-controlled-status]")).toContainText( + "View changed to Month", + ); + + const customRoot = await loadVariant(page, "custom_event"); + await expect( + customRoot.locator("[data-schedule-custom-event='launch']"), + ).toBeVisible(); + await expect( + customRoot.locator("[data-schedule-custom-event]").first(), + ).toContainText("custom body"); + + const recurringRoot = await loadVariant(page, "recurring"); + await expect + .poll(async () => + recurringRoot + .locator("[data-schedule-event]") + .filter({ hasText: "Daily team sync" }) + .count(), + ) + .toBeGreaterThan(1); +}); + +test("static mode keeps navigation but disables drag and resize affordances", async ({ + page, +}) => { + const root = await loadVariant(page, "static"); + const event = visibleEvent(root, "Launch window"); + + await expect(root).toHaveAttribute("data-mode", "static"); + await expect(root.getByRole("button", { name: "Previous" })).toBeVisible(); + await expect(root.getByRole("button", { name: "Next" })).toBeVisible(); + await expect(event).toHaveAttribute("data-draggable", "false"); + await expect(event).toHaveAttribute("data-resizable", "false"); + await expect(root.locator("[data-schedule-resize-handle]")).toHaveCount(0); +}); + +test("responsive layout renders the mobile container and swaps to mobile month at small widths", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + const root = await loadVariant(page, "responsive"); + + await expect(root).toHaveAttribute("data-layout", "responsive"); + await expect(root.locator("[data-schedule-desktop]")).not.toBeVisible(); + await expect(root.locator("[data-schedule-mobile]")).toBeVisible(); + await expect( + root.locator("[data-schedule-mobile] [data-schedule-view='mobile-month']"), + ).toBeVisible(); + await expect( + root.locator("[data-schedule-mobile] [data-mobile-month-view]"), + ).toBeVisible(); + await expect( + root.locator("[data-schedule-mobile] [data-schedule-view='week']"), + ).toHaveCount(0); +}); + +test("mobile month and year views remain reachable in responsive mode", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + const root = await loadVariant(page, "responsive"); + + await viewButton(root, "year").click(); + await expect(root).toHaveAttribute("data-view", "year"); + await expect( + root.locator("[data-schedule-mobile] [data-schedule-view='year']"), + ).toBeVisible(); + + await root.locator("[data-schedule-mobile] [data-schedule-year-month='10']").click(); + await expect(root).toHaveAttribute("data-view", "month"); + await expect( + root.locator("[data-schedule-mobile] [data-schedule-view='mobile-month']"), + ).toBeVisible(); +}); diff --git a/playwright/split_pane.spec.ts b/playwright/split_pane.spec.ts new file mode 100644 index 00000000..1ca36cf7 --- /dev/null +++ b/playwright/split_pane.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Page } from "@playwright/test"; + +const BASE_URL = "http://127.0.0.1:8080"; + +async function gotoSplitPane(page: Page, variant: string) { + await page.goto(`${BASE_URL}/component/block/?name=split_pane&variant=${variant}&`, { + timeout: 20 * 60 * 1000, + waitUntil: "load", + }); +} + +function splitPaneDivider(page: Page, index = 0) { + return page.locator('[role="separator"]').nth(index); +} + +function paneByIndex(page: Page, index: number) { + return page.locator(`[data-pane-index="${index}"]`); +} + +async function readLeftPaneSizeStatus(page: Page) { + const status = page.locator("text=Left pane:"); + await expect(status).toBeVisible(); + const text = (await status.textContent()) ?? ""; + const match = text.match(/Left pane: (\d+)px/); + if (!match) throw new Error(`unexpected status text: ${text}`); + return Number(match[1]); +} + +async function readDividerValue(divider: ReturnType) { + const value = await divider.getAttribute("aria-valuenow"); + if (!value) throw new Error("split pane divider is missing aria-valuenow"); + return Number(value); +} + +test("split pane divider exposes separator semantics and focus", async ({ page }) => { + await gotoSplitPane(page, "main"); + + const divider = splitPaneDivider(page); + + await expect(page.locator('[role="group"][data-orientation="horizontal"]').first()).toHaveAttribute( + "data-resizable", + "true", + ); + await expect(divider).toHaveAttribute("role", "separator"); + await expect(divider).toHaveAttribute("tabindex", "0"); + await expect(divider).toHaveAttribute("aria-orientation", "vertical"); + + await divider.focus(); + await expect(divider).toBeFocused(); +}); + +test("horizontal keyboard resize changes the committed pane size", async ({ page }) => { + await gotoSplitPane(page, "main"); + + const divider = splitPaneDivider(page); + const initialSize = await readDividerValue(divider); + + await divider.focus(); + await page.keyboard.press("ArrowRight"); + const afterRight = await readDividerValue(divider); + expect(afterRight).toBeGreaterThan(initialSize); + + await page.keyboard.press("ArrowLeft"); + const afterLeft = await readDividerValue(divider); + expect(afterLeft).toBeLessThan(afterRight); + + const statusSize = await readLeftPaneSizeStatus(page); + expect(statusSize).toBe(Math.round(afterLeft)); +}); + +test("controlled example commits divider resize updates back into the slider and label", async ({ page }) => { + await gotoSplitPane(page, "controlled"); + + const divider = splitPaneDivider(page); + const slider = page.getByRole("slider", { name: "Controlled pane size" }); + const label = page.getByText(/Sidebar \d+%/); + + await expect(slider).toHaveAttribute("aria-valuenow", "40"); + await expect(label).toHaveText("Sidebar 40%"); + + await divider.focus(); + await expect(divider).toBeFocused(); + await page.keyboard.press("ArrowRight"); + + await expect(slider).not.toHaveAttribute("aria-valuenow", "40"); + await expect(label).not.toHaveText("Sidebar 40%"); + await expect.poll(async () => Number((await slider.getAttribute("aria-valuenow")) ?? "0")).toBeGreaterThan(40); +}); + +test("multi-pane layout keeps both dividers interactive", async ({ page }) => { + await gotoSplitPane(page, "multi_pane"); + + const dividers = page.getByRole("separator"); + await expect(dividers).toHaveCount(2); + + const firstDivider = dividers.first(); + const secondDivider = dividers.nth(1); + const inspectorPane = paneByIndex(page, 2); + + await firstDivider.focus(); + await expect(firstDivider).toBeFocused(); + await page.keyboard.press("ArrowRight"); + + await expect(secondDivider).toBeVisible(); + await expect(inspectorPane).toBeVisible(); +}); diff --git a/preview/Dioxus.toml b/preview/Dioxus.toml new file mode 100644 index 00000000..5b2b7771 --- /dev/null +++ b/preview/Dioxus.toml @@ -0,0 +1,7 @@ +[application] +name = "preview" +asset_dir = "assets" + +[web.watcher] +index_on_404 = true +watch_path = ["src", "../primitives/src", "../dioxus-components/src", "../dioxus-attributes/src"] 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..2570bc0c 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -36,16 +36,16 @@ pub fn category_of(name: &str) -> ComponentCategory { match name { "button" | "input" | "textarea" | "label" | "checkbox" | "switch" | "radio_group" | "toggle" | "toggle_group" | "select" | "slider" | "calendar" | "date_picker" - | "color_picker" => ComponentCategory::Forms, + | "schedule" | "color_picker" | "time_picker" => ComponentCategory::Forms, "navbar" | "sidebar" | "tabs" | "pagination" | "menubar" | "toolbar" | "context_menu" - | "dropdown_menu" => ComponentCategory::Navigation, + | "dropdown_menu" | "table_of_contents" => ComponentCategory::Navigation, "dialog" | "alert_dialog" | "sheet" | "popover" | "tooltip" | "hover_card" => { ComponentCategory::Overlays } "toast" | "progress" | "skeleton" | "badge" => ComponentCategory::Feedback, "accordion" | "collapsible" => ComponentCategory::Disclosure, "avatar" | "card" | "separator" | "aspect_ratio" | "item" | "drag_and_drop_list" - | "virtual_list" | "scroll_area" => ComponentCategory::DataDisplay, + | "virtual_list" | "scroll_area" | "split_pane" => ComponentCategory::DataDisplay, _ => ComponentCategory::DataDisplay, } } @@ -77,6 +77,10 @@ macro_rules! examples { (@kind) => { ComponentType::Normal }; (@kind normal) => { ComponentType::Normal }; (@kind block) => { ComponentType::Block }; + (@variant_name r#static) => { "static" }; + (@variant_name $variant:ident) => { stringify!($variant) }; + (@variant_path r#static) => { "static" }; + (@variant_path $variant:ident) => { stringify!($variant) }; // Normal components: no variant-level css_highlighted (@demo $name:ident $([$($variant:ident),*])?) => { @@ -91,16 +95,16 @@ macro_rules! examples { )), docs: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/docs.html")), component: HighlightedCode { - source: dioxus_code::code!(concat!("/src/components/", stringify!($name), "/component.rs")), + html: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/component.rs.html")), }, style: HighlightedCode { - source: dioxus_code::code!(concat!("/src/components/", stringify!($name), "/style.css")), + html: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/style.css.html")), }, variants: &[ ComponentVariantDemoData { name: "main", rs_highlighted: HighlightedCode { - source: dioxus_code::code!(concat!("/src/components/", stringify!($name), "/variants/main/mod.rs")), + html: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/main/mod.rs.html")), }, css_highlighted: None, component: $name::variants::main::Demo, @@ -108,9 +112,9 @@ macro_rules! examples { $( $( ComponentVariantDemoData { - name: stringify!($variant), + name: examples!(@variant_name $variant), rs_highlighted: HighlightedCode { - source: dioxus_code::code!(concat!("/src/components/", stringify!($name), "/variants/", stringify!($variant), "/mod.rs")), + html: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/", examples!(@variant_path $variant), "/mod.rs.html")), }, css_highlighted: None, component: $name::variants::$variant::Demo, @@ -134,31 +138,31 @@ macro_rules! examples { )), docs: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/docs.html")), component: HighlightedCode { - source: dioxus_code::code!(concat!("/src/components/", stringify!($name), "/component.rs")), + html: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/component.rs.html")), }, style: HighlightedCode { - source: dioxus_code::code!(concat!("/src/components/", stringify!($name), "/style.css")), + html: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/style.css.html")), }, variants: &[ ComponentVariantDemoData { name: "main", rs_highlighted: HighlightedCode { - source: dioxus_code::code!(concat!("/src/components/", stringify!($name), "/variants/main/mod.rs")), + html: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/main/mod.rs.html")), }, css_highlighted: Some(HighlightedCode { - source: dioxus_code::code!(concat!("/src/components/", stringify!($name), "/variants/demo.css")), + html: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/demo.css.html")), }), component: $name::variants::main::Demo, }, $( $( ComponentVariantDemoData { - name: stringify!($variant), + name: examples!(@variant_name $variant), rs_highlighted: HighlightedCode { - source: dioxus_code::code!(concat!("/src/components/", stringify!($name), "/variants/", stringify!($variant), "/mod.rs")), + html: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/", examples!(@variant_path $variant), "/mod.rs.html")), }, css_highlighted: Some(HighlightedCode { - source: dioxus_code::code!(concat!("/src/components/", stringify!($name), "/variants/demo.css")), + html: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/demo.css.html")), }), component: $name::variants::$variant::Demo, }, @@ -181,7 +185,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, @@ -198,15 +202,45 @@ examples!( progress, radio_group, scroll_area, + schedule[ + controlled, + week, + responsive, + r#static, + internationalized, + day, + month, + year, + drag_and_drop, + external_drop, + resize, + slot_selection, + custom_header, + custom_event, + recurring, + multi_view + ], select[multi], separator, sheet, sidebar(block)[floating, inset], skeleton, + split_pane[ + vertical, + multi_pane, + controlled, + constraints, + nested, + snap, + custom_divider, + persistence + ], slider[dynamic_range, range], switch, tabs, - textarea[outline, fade, ghost], + table_of_contents, + textarea[outline, fade, ghost, bottom_section, autosize, resize], + time_picker, toast, toggle, toggle_group, diff --git a/preview/src/components/schedule/component.json b/preview/src/components/schedule/component.json new file mode 100644 index 00000000..0bb43215 --- /dev/null +++ b/preview/src/components/schedule/component.json @@ -0,0 +1,18 @@ +{ + "name": "schedule", + "description": "A responsive schedule component for calendar views, events, recurrence, and scheduling interactions.", + "authors": ["Dioxus Labs"], + "exclude": ["variants", "docs.md", "component.json"], + "cargoDependencies": [ + { + "name": "dioxus-primitives", + "git": "https://github.com/DioxusLabs/components" + }, + { + "name": "time", + "version": "0.3.44", + "features": ["macros"] + } + ], + "globalAssets": ["../../../assets/dx-components-theme.css"] +} diff --git a/preview/src/components/schedule/component.rs b/preview/src/components/schedule/component.rs new file mode 100644 index 00000000..5fe5713c --- /dev/null +++ b/preview/src/components/schedule/component.rs @@ -0,0 +1,1042 @@ +use crate::components::button::{Button, ButtonSize, ButtonVariant}; +use crate::components::date_picker::DatePicker; +use dioxus::prelude::*; +use dioxus_primitives::dioxus_attributes::attributes; +use dioxus_primitives::merge_attributes; +pub use dioxus_primitives::schedule::{ + add_months, shift_date, today, ScheduleAllDaySlotClick, ScheduleClassNames, + ScheduleDateChange, ScheduleDayClick, ScheduleDayViewConfig, ScheduleDropDestination, + ScheduleEvent, ScheduleEventClick, ScheduleEventCreate, ScheduleEventCreateSource, + ScheduleEventDrag, ScheduleEventDrop, ScheduleEventRenderContext, ScheduleEventResize, + ScheduleExternalDrop, ScheduleLabels, ScheduleLayout, ScheduleMobileMonthViewConfig, + ScheduleMode, ScheduleMonthViewConfig, ScheduleRecurrence, ScheduleRecurrenceExpansionLimit, + ScheduleRecurrenceFrequency, ScheduleSlotRangeSelection, ScheduleTimeGridConfig, + ScheduleTimeSlotClick, ScheduleView, ScheduleViewChange, ScheduleWeekViewConfig, + ScheduleYearViewConfig, +}; +pub type ScheduleResizeEdge = dioxus_primitives::schedule::ScheduleResizeEdge; +use time::{macros::time, Date, Duration, PrimitiveDateTime}; + +#[css_module("/src/components/schedule/style.css")] +struct Styles; + +/// A styled schedule surface for day, week, month, and year planning views. +#[derive(Props, Clone, PartialEq)] +pub struct ScheduleProps { + /// Controlled active date. + #[props(default)] + pub date: ReadSignal>, + /// Default active date for uncontrolled usage. + #[props(default = sample_date())] + pub default_date: Date, + /// Callback fired after the active date changes. + #[props(default)] + pub on_date_change: Callback, + /// Controlled active view. + #[props(default)] + pub view: ReadSignal>, + /// Default active view for uncontrolled usage. + #[props(default)] + pub default_view: ScheduleView, + /// Callback fired after the active view changes. + #[props(default)] + pub on_view_change: Callback, + /// Interaction mode. + #[props(default)] + pub mode: ScheduleMode, + /// Layout strategy. + #[props(default)] + pub layout: ScheduleLayout, + /// Events to render. + #[props(default = sample_events())] + pub events: Vec, + /// Recurrence expansion limit. + #[props(default)] + pub recurrence_expansion_limit: ScheduleRecurrenceExpansionLimit, + /// Locale identifier exposed to the primitive. + #[props(default = "en-US".to_string())] + pub locale: String, + /// Visible labels for navigation, views, and empty regions. + #[props(default)] + pub labels: ScheduleLabels, + /// Day view configuration. + #[props(default)] + pub day_view: ScheduleDayViewConfig, + /// Week view configuration. + #[props(default = work_week_config())] + pub week_view: ScheduleWeekViewConfig, + /// Month view configuration. + #[props(default)] + pub month_view: ScheduleMonthViewConfig, + /// Year view configuration. + #[props(default)] + pub year_view: ScheduleYearViewConfig, + /// Mobile month view configuration used in responsive layout. + #[props(default)] + pub mobile_month_view: ScheduleMobileMonthViewConfig, + /// Whether to render the default schedule header. + #[props(default = true)] + pub with_default_header: bool, + /// Custom schedule header content. + #[props(default)] + pub header: Option, + /// Radius token applied through the primitive style variable. + #[props(default)] + pub radius: Option, + /// Stable primitive class hooks for advanced styling. + #[props(default)] + pub class_names: ScheduleClassNames, + /// Enable internal event drag/drop. + #[props(default)] + pub with_events_drag_and_drop: bool, + /// Enable drag-to-select slots. + #[props(default)] + pub with_drag_slot_select: bool, + /// Enable event resizing. + #[props(default)] + pub with_event_resize: bool, + /// Event drag guard. + #[props(default = Callback::new(|event: ScheduleEvent| !event.drag_disabled))] + pub can_drag_event: Callback, + /// Event resize guard. + #[props(default = Callback::new(|event: ScheduleEvent| !event.resize_disabled))] + pub can_resize_event: Callback, + /// Custom event body renderer. + #[props(default)] + pub render_event_body: Option>, + /// Called when a time slot is clicked. + #[props(default)] + pub on_time_slot_click: Callback, + /// Called when an all-day slot is clicked. + #[props(default)] + pub on_all_day_slot_click: Callback, + /// Called when a day cell is clicked. + #[props(default)] + pub on_day_click: Callback, + /// Called when an empty schedule slot requests event creation. + #[props(default)] + pub on_event_create: Callback, + /// Called when an event is clicked. + #[props(default)] + pub on_event_click: Callback, + /// Called when event dragging starts. + #[props(default)] + pub on_event_drag_start: Callback, + /// Called when event dragging ends. + #[props(default)] + pub on_event_drag_end: Callback, + /// Called when an event is dropped onto a schedule target. + #[props(default)] + pub on_event_drop: Callback, + /// Called when external data is dropped onto a schedule target. + #[props(default)] + pub on_external_event_drop: Callback, + /// Called when drag slot selection completes. + #[props(default)] + pub on_slot_drag_end: Callback, + /// Called when an event resize completes. + #[props(default)] + pub on_event_resize: Callback, + /// Additional attributes for the schedule root. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} + +#[component] +pub fn Schedule(props: ScheduleProps) -> Element { + let base = attributes!(div { + class: Styles::dx_schedule, + }); + let merged = merge_attributes(vec![base, props.attributes]); + let mut internal_date = use_signal(|| props.default_date); + let mut internal_view = use_signal(|| props.default_view); + let active_date = match (props.date)() { + Some(date) => date, + None => internal_date(), + }; + let active_view = match (props.view)() { + Some(view) => view, + None => internal_view(), + }; + let labels = props.labels.clone(); + let user_date_change = props.on_date_change; + let user_view_change = props.on_view_change; + let set_date = move |next: Date| { + let previous = active_date; + internal_date.set(next); + user_date_change.call(ScheduleDateChange { + previous, + next, + view: active_view, + }); + }; + let set_view = move |next: ScheduleView| { + let previous = active_view; + internal_view.set(next); + user_view_change.call(ScheduleViewChange { + previous, + next, + date: active_date, + }); + }; + let header = if let Some(header) = props.header { + Some(header) + } else if props.with_default_header { + Some(rsx! { + ScheduleHeader { + date: active_date, + view: active_view, + labels: labels.clone(), + on_date: set_date, + on_view: set_view, + } + }) + } else { + None + }; + let on_date_change = move |payload: ScheduleDateChange| { + internal_date.set(payload.next); + user_date_change.call(payload); + }; + let on_view_change = move |payload: ScheduleViewChange| { + internal_view.set(payload.next); + user_view_change.call(payload); + }; + + rsx! { + dioxus_primitives::schedule::Schedule { + date: Some(active_date), + default_date: props.default_date, + on_date_change, + view: Some(active_view), + default_view: props.default_view, + on_view_change, + mode: props.mode, + layout: props.layout, + events: props.events, + recurrence_expansion_limit: props.recurrence_expansion_limit, + locale: props.locale, + labels: props.labels, + day_view: props.day_view, + week_view: props.week_view, + month_view: props.month_view, + year_view: props.year_view, + mobile_month_view: props.mobile_month_view, + with_default_header: false, + header, + radius: props.radius, + class_names: props.class_names, + with_events_drag_and_drop: props.with_events_drag_and_drop, + with_drag_slot_select: props.with_drag_slot_select, + with_event_resize: props.with_event_resize, + can_drag_event: props.can_drag_event, + can_resize_event: props.can_resize_event, + render_event_body: props.render_event_body, + on_time_slot_click: props.on_time_slot_click, + on_all_day_slot_click: props.on_all_day_slot_click, + on_day_click: props.on_day_click, + on_event_create: props.on_event_create, + on_event_click: props.on_event_click, + on_event_drag_start: props.on_event_drag_start, + on_event_drag_end: props.on_event_drag_end, + on_event_drop: props.on_event_drop, + on_external_event_drop: props.on_external_event_drop, + on_slot_drag_end: props.on_slot_drag_end, + on_event_resize: props.on_event_resize, + attributes: merged, + } + } +} + +#[component] +fn ScheduleHeader( + date: Date, + view: ScheduleView, + labels: ScheduleLabels, + on_date: Callback, + on_view: Callback, +) -> Element { + rsx! { + header { "data-schedule-header": true, + div { "data-schedule-header-navigation": true, + Button { + variant: ButtonVariant::Outline, + size: ButtonSize::Sm, + aria_label: labels.previous.clone(), + onclick: move |_| on_date.call(shift_date(date, view, -1)), + "‹" + } + DatePicker { + "data-schedule-date-picker": true, + selected_date: Some(date), + on_value_change: move |next| { + if let Some(next) = next { + on_date.call(next); + } + }, + } + Button { + variant: ButtonVariant::Outline, + size: ButtonSize::Sm, + aria_label: labels.next.clone(), + onclick: move |_| on_date.call(shift_date(date, view, 1)), + "›" + } + Button { + variant: ButtonVariant::Outline, + size: ButtonSize::Sm, + onclick: move |_| on_date.call(today()), + {labels.today.clone()} + } + } + nav { + "aria-label": "Schedule views", + "data-schedule-view-controls": true, + ScheduleViewButton { + target: ScheduleView::Day, + current: view, + label: labels.day, + on_view, + } + ScheduleViewButton { + target: ScheduleView::Week, + current: view, + label: labels.week, + on_view, + } + ScheduleViewButton { + target: ScheduleView::Month, + current: view, + label: labels.month, + on_view, + } + ScheduleViewButton { + target: ScheduleView::Year, + current: view, + label: labels.year, + on_view, + } + } + } + } +} + +#[component] +fn ScheduleViewButton( + target: ScheduleView, + current: ScheduleView, + label: String, + on_view: Callback, +) -> Element { + rsx! { + Button { + variant: if target == current { ButtonVariant::Primary } else { ButtonVariant::Outline }, + size: ButtonSize::Sm, + "data-schedule-view-button": target.as_str(), + "data-active": target == current, + onclick: move |_| on_view.call(target), + {label} + } + } +} + +/// Returns the preview date used by the schedule examples. +pub fn sample_date() -> Date { + today() +} + +/// Returns realistic preview events covering all-day, timed, overlapping, colored, and recurring cases. +pub fn sample_events() -> Vec { + let anchor = sample_date(); + let month_start = Date::from_calendar_date(anchor.year(), anchor.month(), 1).unwrap(); + let next_month_start = add_months(month_start, 1); + let month_border_start = next_month_start - Duration::days(1); + let days_until_sunday = (7 - anchor.weekday().number_days_from_sunday() as i64) % 7; + let week_border_start = anchor + Duration::days(days_until_sunday - 1); + + vec![ + event( + anchor, + "launch", + "Launch window", + event_time(0, 9, 0, 10, 30), + "blue", + ) + .with_description("Timed launch planning window"), + event( + anchor, + "design", + "Design review", + event_time(0, 9, 30, 11, 0), + "violet", + ), + event( + anchor, + "support", + "Support handoff", + event_time(0, 10, 15, 12, 0), + "green", + ), + event( + anchor, + "research", + "Customer interviews", + event_time(1, 13, 0, 15, 0), + "orange", + ), + event( + anchor, + "sync", + "Daily team sync", + event_time(2, 9, 0, 9, 30), + "teal", + ) + .recurring(ScheduleRecurrenceFrequency::Daily, 1, Some(4)), + event( + anchor, + "planning", + "Sprint planning", + event_time(3, 11, 0, 12, 30), + "pink", + ), + event( + anchor, + "readout", + "Executive readout", + event_time(4, 15, 0, 16, 0), + "gray", + ) + .drag_disabled(), + all_day_event(anchor, "onsite", "Team onsite", 0, 2, "amber"), + all_day_event(anchor, "freeze", "Release freeze", 4, 4, "red").resize_disabled(), + all_day_event_on_dates( + "week-border", + "Weekend handoff", + week_border_start, + week_border_start + Duration::days(2), + "cyan", + ), + all_day_event_on_dates( + "month-border", + "Month-end rollout", + month_border_start, + next_month_start, + "lime", + ), + event(anchor, "retro", "Retro", event_time(4, 10, 0, 11, 0), "indigo").recurring( + ScheduleRecurrenceFrequency::Weekly, + 1, + Some(3), + ), + ] +} + +pub(crate) fn apply_demo_event_drop( + events: &mut Vec, + payload: &ScheduleEventDrop, + limit: ScheduleRecurrenceExpansionLimit, +) { + if let Some(event) = editable_demo_event(events, &payload.event_id, limit) { + event.start = payload.new_start; + event.end = payload.new_end; + event.all_day = payload.destination == ScheduleDropDestination::AllDay; + } +} + +pub(crate) fn apply_demo_event_resize( + events: &mut Vec, + payload: &ScheduleEventResize, + limit: ScheduleRecurrenceExpansionLimit, +) { + if let Some(event) = editable_demo_event(events, &payload.event_id, limit) { + event.start = payload.new_start; + event.end = payload.new_end; + } +} + +/// Returns French labels used by the internationalized example. +pub fn french_labels() -> ScheduleLabels { + ScheduleLabels { + previous: "Precedent".to_string(), + next: "Suivant".to_string(), + today: "Aujourd'hui".to_string(), + day: "Jour".to_string(), + week: "Semaine".to_string(), + month: "Mois".to_string(), + year: "Annee".to_string(), + all_day: "Journee".to_string(), + empty_slot: "Aucun evenement".to_string(), + } +} + +/// Returns a compact workday time grid for examples. +pub fn workday_time_grid() -> ScheduleTimeGridConfig { + ScheduleTimeGridConfig { + with_default_header: true, + start_hour: 7, + end_hour: 18, + slot_minutes: 60, + } +} + +fn work_week_config() -> ScheduleWeekViewConfig { + ScheduleWeekViewConfig { + time_grid: workday_time_grid(), + ..ScheduleWeekViewConfig::default() + } +} + +fn event(anchor: Date, id: &str, title: &str, time: EventTime, color: &str) -> ScheduleEvent { + ScheduleEvent { + id: id.to_string(), + title: title.to_string(), + start: datetime(anchor + Duration::days(time.day_offset), time.start_hour, time.start_minute), + end: datetime(anchor + Duration::days(time.day_offset), time.end_hour, time.end_minute), + all_day: false, + color: Some(color.to_string()), + description: None, + recurrence: None, + drag_disabled: false, + resize_disabled: false, + } +} + +fn event_time( + day_offset: i64, + start_hour: u8, + start_minute: u8, + end_hour: u8, + end_minute: u8, +) -> EventTime { + EventTime { + day_offset, + start_hour, + start_minute, + end_hour, + end_minute, + } +} + +struct EventTime { + day_offset: i64, + start_hour: u8, + start_minute: u8, + end_hour: u8, + end_minute: u8, +} + +fn all_day_event( + anchor: Date, + id: &str, + title: &str, + start_day_offset: i64, + end_day_offset: i64, + color: &str, +) -> ScheduleEvent { + all_day_event_on_dates( + id, + title, + anchor + Duration::days(start_day_offset), + anchor + Duration::days(end_day_offset), + color, + ) +} + +fn all_day_event_on_dates( + id: &str, + title: &str, + start_date: Date, + end_date: Date, + color: &str, +) -> ScheduleEvent { + ScheduleEvent { + id: id.to_string(), + title: title.to_string(), + start: datetime(start_date, 0, 0), + end: datetime(end_date, 23, 59) + Duration::minutes(1), + all_day: true, + color: Some(color.to_string()), + description: None, + recurrence: None, + drag_disabled: false, + resize_disabled: false, + } +} + +fn datetime(date: Date, hour: u8, minute: u8) -> PrimitiveDateTime { + PrimitiveDateTime::new( + date, + time!(00:00) + Duration::hours(hour as i64) + Duration::minutes(minute as i64), + ) +} + +trait ScheduleEventPreviewExt { + fn with_description(self, description: &str) -> Self; + fn recurring( + self, + frequency: ScheduleRecurrenceFrequency, + interval: u32, + count: Option, + ) -> Self; + fn drag_disabled(self) -> Self; + fn resize_disabled(self) -> Self; +} + +impl ScheduleEventPreviewExt for ScheduleEvent { + fn with_description(mut self, description: &str) -> Self { + self.description = Some(description.to_string()); + self + } + + fn recurring( + mut self, + frequency: ScheduleRecurrenceFrequency, + interval: u32, + count: Option, + ) -> Self { + self.recurrence = Some(ScheduleRecurrence { + frequency, + interval, + count, + until: None, + }); + self + } + + fn drag_disabled(mut self) -> Self { + self.drag_disabled = true; + self + } + + fn resize_disabled(mut self) -> Self { + self.resize_disabled = true; + self + } +} + +fn editable_demo_event<'a>( + events: &'a mut Vec, + event_id: &str, + limit: ScheduleRecurrenceExpansionLimit, +) -> Option<&'a mut ScheduleEvent> { + let editable_id = if let Some(index) = events + .iter() + .position(|event| event.id == event_id && event.recurrence.is_none()) + { + events[index].id.clone() + } else if detach_recurring_occurrence(events, event_id, limit).is_some() { + event_id.to_string() + } else { + events + .iter() + .find(|event| event.id == event_id) + .map(|event| event.id.clone())? + }; + + events.iter_mut().find(|event| event.id == editable_id) +} + +fn detach_recurring_occurrence( + events: &mut Vec, + occurrence_id: &str, + limit: ScheduleRecurrenceExpansionLimit, +) -> Option { + let target = resolve_recurring_occurrence(events, occurrence_id, limit)?; + let source = events.remove(target.source_index); + + let mut replacements = Vec::with_capacity(3); + if target.occurrence_index > 0 { + let mut leading = source.clone(); + leading.recurrence.as_mut().unwrap().count = Some(target.occurrence_index); + replacements.push(leading); + } + + let mut detached = target.occurrence.clone(); + detached.recurrence = None; + replacements.push(detached); + + if let Some((next_start, next_end)) = target.next_occurrence_bounds { + let mut trailing = source; + let trailing_count = trailing + .recurrence + .as_ref() + .and_then(|recurrence| recurrence.count) + .map(|count| count - target.occurrence_index - 1); + trailing.id = recurring_continuation_id(&trailing.id, target.occurrence_index + 1); + trailing.start = next_start; + trailing.end = next_end; + trailing.recurrence.as_mut().unwrap().count = trailing_count; + replacements.push(trailing); + } + + let insert_at = target.source_index; + events.splice(insert_at..insert_at, replacements); + events + .iter() + .position(|event| event.id == occurrence_id && event.recurrence.is_none()) +} + +fn resolve_recurring_occurrence( + events: &[ScheduleEvent], + occurrence_id: &str, + limit: ScheduleRecurrenceExpansionLimit, +) -> Option { + let (source_id, occurrence_index) = recurrence_identity(events, occurrence_id)?; + let source_index = events + .iter() + .position(|event| event.id == source_id && event.recurrence.is_some())?; + let source = &events[source_index]; + let occurrences = expand_preview_recurrence(source, limit); + let occurrence = occurrences.get(occurrence_index)?.clone(); + let next_occurrence_bounds = recurrence_occurrence_at(source, occurrence_index + 1) + .map(|occurrence| (occurrence.start, occurrence.end)); + + Some(RecurringOccurrenceTarget { + source_index, + occurrence_index, + occurrence, + next_occurrence_bounds, + }) +} + +fn recurrence_identity<'a>( + events: &'a [ScheduleEvent], + occurrence_id: &str, +) -> Option<(&'a str, usize)> { + if let Some(event) = events + .iter() + .find(|event| event.id == occurrence_id && event.recurrence.is_some()) + { + return Some((event.id.as_str(), 0)); + } + + let (source_id, index) = occurrence_id.rsplit_once(':')?; + let occurrence_index = index.parse::().ok()?; + events + .iter() + .find(|event| event.id == source_id && event.recurrence.is_some()) + .map(|event| (event.id.as_str(), occurrence_index)) +} + +fn recurring_continuation_id(source_id: &str, occurrence_index: usize) -> String { + format!("__demo_recurrence_after__{source_id}__{occurrence_index}") +} + +fn expand_preview_recurrence( + event: &ScheduleEvent, + limit: ScheduleRecurrenceExpansionLimit, +) -> Vec { + let Some(recurrence) = &event.recurrence else { + return vec![event.clone()]; + }; + let max_occurrences = recurrence + .count + .unwrap_or(limit.max_occurrences) + .min(limit.max_occurrences); + let mut occurrences = Vec::new(); + + for index in 0..max_occurrences { + let Some(occurrence) = recurrence_occurrence_at(event, index) else { + break; + }; + occurrences.push(occurrence); + } + + occurrences +} + +struct RecurringOccurrenceTarget { + source_index: usize, + occurrence_index: usize, + occurrence: ScheduleEvent, + next_occurrence_bounds: Option<(PrimitiveDateTime, PrimitiveDateTime)>, +} + +fn recurrence_occurrence_at( + event: &ScheduleEvent, + occurrence_index: usize, +) -> Option { + let recurrence = event.recurrence.as_ref()?; + if recurrence + .count + .is_some_and(|count| occurrence_index >= count) + { + return None; + } + + let duration = event.end - event.start; + let start = advance_recurrence_start(event.start, recurrence, occurrence_index); + if recurrence.until.is_some_and(|until| start > until) { + return None; + } + + let mut occurrence = event.clone(); + occurrence.id = if occurrence_index == 0 { + event.id.clone() + } else { + format!("{}:{occurrence_index}", event.id) + }; + occurrence.start = start; + occurrence.end = start + duration; + occurrence.recurrence = None; + Some(occurrence) +} + +fn advance_recurrence_start( + start: PrimitiveDateTime, + recurrence: &ScheduleRecurrence, + occurrence_index: usize, +) -> PrimitiveDateTime { + let interval = recurrence.interval.max(1) as i64; + match recurrence.frequency { + ScheduleRecurrenceFrequency::Daily => { + start + Duration::days(interval * occurrence_index as i64) + } + ScheduleRecurrenceFrequency::Weekly => { + start + Duration::weeks(interval * occurrence_index as i64) + } + ScheduleRecurrenceFrequency::Monthly => PrimitiveDateTime::new( + add_months(start.date(), (interval * occurrence_index as i64) as i32), + start.time(), + ), + ScheduleRecurrenceFrequency::Yearly => PrimitiveDateTime::new( + add_months( + start.date(), + (interval * 12 * occurrence_index as i64) as i32, + ), + start.time(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_datetime(day_offset: i64, hour: u8, minute: u8) -> PrimitiveDateTime { + datetime(sample_date() + Duration::days(day_offset), hour, minute) + } + + fn drop_payload( + event_id: &str, + new_start: PrimitiveDateTime, + new_end: PrimitiveDateTime, + destination: ScheduleDropDestination, + ) -> ScheduleEventDrop { + ScheduleEventDrop { + event_id: event_id.to_string(), + event: ScheduleEvent { + id: event_id.to_string(), + title: "Edited".to_string(), + start: new_start, + end: new_end, + all_day: destination == ScheduleDropDestination::AllDay, + color: None, + description: None, + recurrence: None, + drag_disabled: false, + resize_disabled: false, + }, + new_start, + new_end, + destination, + date: new_start.date(), + view: ScheduleView::Week, + } + } + + fn resize_payload( + event_id: &str, + new_start: PrimitiveDateTime, + new_end: PrimitiveDateTime, + ) -> ScheduleEventResize { + ScheduleEventResize { + event_id: event_id.to_string(), + event: ScheduleEvent { + id: event_id.to_string(), + title: "Edited".to_string(), + start: new_start, + end: new_end, + all_day: false, + color: None, + description: None, + recurrence: None, + drag_disabled: false, + resize_disabled: false, + }, + new_start, + new_end, + edge: dioxus_primitives::schedule::ScheduleResizeEdge::End, + view: ScheduleView::Week, + } + } + + #[test] + fn editing_one_recurring_occurrence_detaches_only_that_occurrence() { + let mut events = sample_events(); + let new_start = sample_datetime(3, 7, 0); + let new_end = sample_datetime(3, 7, 30); + + apply_demo_event_resize( + &mut events, + &resize_payload("sync:1", new_start, new_end), + ScheduleRecurrenceExpansionLimit::default(), + ); + + let detached = events.iter().find(|event| event.id == "sync:1").unwrap(); + assert_eq!(detached.start, new_start); + assert_eq!(detached.end, new_end); + assert!(detached.recurrence.is_none()); + + let source = events.iter().find(|event| event.id == "sync").unwrap(); + assert_eq!(source.start, sample_datetime(2, 9, 0)); + assert_eq!(source.recurrence.as_ref().unwrap().count, Some(1)); + + let trailing = events + .iter() + .find(|event| event.id == recurring_continuation_id("sync", 2)) + .unwrap(); + assert!(trailing.recurrence.is_some()); + assert_eq!(trailing.start, sample_datetime(4, 9, 0)); + assert_eq!(trailing.recurrence.as_ref().unwrap().count, Some(2)); + } + + #[test] + fn editing_first_recurring_occurrence_detaches_only_that_occurrence() { + let mut events = sample_events(); + let new_start = sample_datetime(2, 7, 0); + let new_end = sample_datetime(2, 7, 30); + + apply_demo_event_resize( + &mut events, + &resize_payload("sync", new_start, new_end), + ScheduleRecurrenceExpansionLimit::default(), + ); + + let detached = events.iter().find(|event| event.id == "sync").unwrap(); + assert_eq!(detached.start, new_start); + assert_eq!(detached.end, new_end); + assert!(detached.recurrence.is_none()); + + let trailing = events + .iter() + .find(|event| event.id == recurring_continuation_id("sync", 1)) + .unwrap(); + assert!(trailing.recurrence.is_some()); + assert_eq!(trailing.start, sample_datetime(3, 9, 0)); + assert_eq!(trailing.recurrence.as_ref().unwrap().count, Some(3)); + } + + #[test] + fn dragging_recurring_occurrence_to_all_day_detaches_only_that_occurrence() { + let mut events = sample_events(); + let new_start = sample_datetime(3, 0, 0); + let new_end = sample_datetime(3, 23, 59) + Duration::minutes(1); + + apply_demo_event_drop( + &mut events, + &drop_payload( + "sync:1", + new_start, + new_end, + ScheduleDropDestination::AllDay, + ), + ScheduleRecurrenceExpansionLimit::default(), + ); + + let detached = events.iter().find(|event| event.id == "sync:1").unwrap(); + assert_eq!(detached.start, new_start); + assert_eq!(detached.end, new_end); + assert!(detached.all_day); + assert!(detached.recurrence.is_none()); + + let source = events.iter().find(|event| event.id == "sync").unwrap(); + assert_eq!(source.recurrence.as_ref().unwrap().count, Some(1)); + + let trailing = events + .iter() + .find(|event| event.id == recurring_continuation_id("sync", 2)) + .unwrap(); + assert!(trailing.recurrence.is_some()); + assert_eq!(trailing.start, sample_datetime(4, 9, 0)); + } + + #[test] + fn non_recurring_ids_with_numeric_suffix_stay_directly_editable() { + let start = sample_datetime(0, 13, 0); + let end = sample_datetime(0, 14, 0); + let mut events = vec![ScheduleEvent { + id: "design:1".to_string(), + title: "Design review".to_string(), + start, + end, + all_day: false, + color: None, + description: None, + recurrence: None, + drag_disabled: false, + resize_disabled: false, + }]; + let new_start = sample_datetime(0, 15, 0); + let new_end = sample_datetime(0, 16, 0); + + apply_demo_event_resize( + &mut events, + &resize_payload("design:1", new_start, new_end), + ScheduleRecurrenceExpansionLimit::default(), + ); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].id, "design:1"); + assert_eq!(events[0].start, new_start); + assert_eq!(events[0].end, new_end); + } + + #[test] + fn editing_last_visible_occurrence_preserves_unexpanded_recurring_tail() { + let start = sample_datetime(0, 9, 0); + let end = sample_datetime(0, 9, 30); + let mut events = vec![ScheduleEvent { + id: "series".to_string(), + title: "Long series".to_string(), + start, + end, + all_day: false, + color: None, + description: None, + recurrence: Some(ScheduleRecurrence { + frequency: ScheduleRecurrenceFrequency::Daily, + interval: 1, + count: Some(10), + until: None, + }), + drag_disabled: false, + resize_disabled: false, + }]; + let new_start = sample_datetime(4, 7, 0); + let new_end = sample_datetime(4, 7, 30); + let limit = ScheduleRecurrenceExpansionLimit { max_occurrences: 5 }; + + apply_demo_event_resize( + &mut events, + &resize_payload("series:4", new_start, new_end), + limit, + ); + + let leading = events.iter().find(|event| event.id == "series").unwrap(); + assert_eq!(leading.recurrence.as_ref().unwrap().count, Some(4)); + + let detached = events.iter().find(|event| event.id == "series:4").unwrap(); + assert_eq!(detached.start, new_start); + assert_eq!(detached.end, new_end); + assert!(detached.recurrence.is_none()); + + let trailing = events + .iter() + .find(|event| event.id == recurring_continuation_id("series", 5)) + .unwrap(); + assert_eq!(trailing.start, sample_datetime(5, 9, 0)); + assert_eq!(trailing.end, sample_datetime(5, 9, 30)); + assert_eq!(trailing.recurrence.as_ref().unwrap().count, Some(5)); + } +} diff --git a/preview/src/components/schedule/docs.md b/preview/src/components/schedule/docs.md new file mode 100644 index 00000000..f1ffe255 --- /dev/null +++ b/preview/src/components/schedule/docs.md @@ -0,0 +1,71 @@ +# Schedule + +The schedule component renders day, week, month, and year calendar views with timed events, all-day events, recurrence expansion, responsive mobile layout, and scheduling interactions. + +## Usage + +```rust +Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + events: sample_events(), +} +``` + +## Views + +Use `default_view` for uncontrolled view state or `view` with `on_view_change` for controlled state. Supported views are `ScheduleView::Day`, `ScheduleView::Week`, `ScheduleView::Month`, and `ScheduleView::Year`. The header exposes navigation and view controls, and selecting a month in the year view moves to month view. + +## Controlled State + +Use `date` and `on_date_change` to control the visible date. Use `view` and `on_view_change` to control the visible view. The callback payloads include previous and next values plus the active view or date so application state can stay synchronized. + +## Events And Recurrence + +Events use stable ids, titles, start and end date-times, all-day state, optional colors, descriptions, and optional recurrence rules. `recurrence_expansion_limit` bounds repeated events. The preview data includes timed, all-day, overlapping, colored, daily recurring, and weekly recurring events. + +## Interactions + +Enable `with_events_drag_and_drop`, `with_drag_slot_select`, and `with_event_resize` to expose drag/drop, slot range selection, and resize behavior. Handlers receive typed payloads for time slots, all-day slots, day cells, event clicks, drag start/end, internal drops, external drops, slot selections, and resize completions. `can_drag_event` and `can_resize_event` can prevent specific events from moving or resizing. + +Use `on_event_create` for built-in event creation. Timed slot clicks emit a `ScheduleEventCreate` with the slot `start` and `end`. Timed drag selection emits one create payload for the normalized selected range. All-day slot clicks and day-cell clicks emit full-day ranges. The payload includes `date`, `all_day`, `view`, and `source` (`TimeSlotClick`, `TimeSlotDrag`, `AllDaySlotClick`, or `DayClick`) so applications can append an event without stitching together the legacy click and selection callbacks. + +## Responsive Layout + +Set `layout: ScheduleLayout::Responsive` to render both desktop and mobile containers. CSS switches to the mobile month presentation at small widths while keeping the year view available. + +## Custom Rendering And Header + +Use `render_event_body` to replace the default event body. Use `with_default_header: false` to suppress the top-level schedule header, pass `header` to replace it with custom content, or compose with the exported primitive `ScheduleHeader` and `ScheduleViewButton` controls. Per-view config structs expose `with_default_header` toggles for the day, week, month, year, and mobile month view headers. + +## Styling + +The primitive exposes a `radius` style variable, stateful `data-*` attributes, and `ScheduleClassNames` hooks for desktop/mobile containers, day/week/month/year/mobile-month surfaces, slots, days, and events. + +## Localization And Labels + +Set `locale` and pass `ScheduleLabels` to localize visible navigation, view names, all-day labels, and empty slot text. + +## Static Mode + +Set `mode: ScheduleMode::Static` to keep navigation and selection available while disabling event drag/drop and event resize, even when those interactive props are enabled. + +## Accessibility + +The primitive renders buttons for navigation, view controls, dates, months, and slots. Provide meaningful event titles and descriptions, keep custom event bodies readable, and retain visible focus states when extending the styles. + +## Component Structure + +```rust +Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + layout: ScheduleLayout::Responsive, + mode: ScheduleMode::Default, + events: sample_events(), + labels: ScheduleLabels::default(), + with_events_drag_and_drop: true, + with_drag_slot_select: true, + with_event_resize: true, +} +``` diff --git a/preview/src/components/schedule/mod.rs b/preview/src/components/schedule/mod.rs new file mode 100644 index 00000000..2590c013 --- /dev/null +++ b/preview/src/components/schedule/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/preview/src/components/schedule/style.css b/preview/src/components/schedule/style.css new file mode 100644 index 00000000..521f8cae --- /dev/null +++ b/preview/src/components/schedule/style.css @@ -0,0 +1,725 @@ +.dx-schedule { + --schedule-surface: var(--primary-color-2); + --schedule-surface-muted: var(--primary-color-2); + --schedule-surface-emphasis: var(--primary-color-3); + --schedule-border: color-mix(in srgb, var(--secondary-color-6) 55%, transparent); + --schedule-border-soft: color-mix(in srgb, var(--secondary-color-6) 28%, transparent); + --schedule-text: var(--secondary-color-2); + --schedule-muted: var(--secondary-color-5); + --schedule-focus: var(--focused-border-color); + --schedule-selected: color-mix(in srgb, var(--schedule-focus) 14%, var(--schedule-surface)); + --schedule-selected-strong: color-mix(in srgb, var(--schedule-focus) 20%, var(--schedule-surface)); + --schedule-range: color-mix(in srgb, var(--secondary-success-color) 16%, var(--schedule-surface)); + --schedule-range-border: color-mix(in srgb, var(--secondary-success-color) 62%, transparent); + --schedule-drag: color-mix(in srgb, var(--secondary-warning-color) 18%, var(--schedule-surface)); + --schedule-drop: color-mix(in srgb, var(--secondary-warning-color) 20%, var(--schedule-surface)); + --schedule-drop-ring: color-mix(in srgb, var(--secondary-warning-color) 88%, transparent); + --schedule-resize-drop: color-mix(in srgb, var(--schedule-focus) 14%, var(--schedule-surface)); + --schedule-resize-ring: color-mix(in srgb, var(--schedule-focus) 72%, transparent); + --schedule-drop-denied: color-mix(in srgb, var(--secondary-error-color) 14%, var(--schedule-surface)); + --schedule-drop-denied-ring: color-mix(in srgb, var(--secondary-error-color) 80%, transparent); + --schedule-radius: var(--dxc-schedule-radius, 10px); + --schedule-time-slot-size: 3.1rem; + + overflow: hidden; + width: 100%; + border: 1px solid var(--schedule-border); + border-radius: var(--schedule-radius); + background: var(--schedule-surface); + color: var(--schedule-text); +} + +.dx-schedule :where(button) { + color: inherit; + font: inherit; +} + +.dx-schedule :where(button:focus-visible, article:focus-visible) { + outline: 2px solid var(--schedule-focus); + outline-offset: 2px; +} + +.dx-schedule [data-schedule-header] { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-bottom: 1px solid var(--schedule-border); + background: var(--schedule-surface-muted); + gap: 12px; +} + +.dx-schedule [data-schedule-header-navigation] { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.dx-schedule [data-schedule-date-picker] { + min-width: 176px; +} + +.dx-schedule [data-schedule-header-navigation] [data-schedule-date-picker] .dx-date-picker-group { + min-width: 176px; + min-height: 34px; + border-radius: 7px; + background: var(--schedule-surface); + box-shadow: + inset 0 0 0 1px var(--schedule-border), + 0 1px 2px color-mix(in srgb, var(--secondary-color) 7%, transparent); + color: var(--schedule-text); +} + +.dx-schedule [data-schedule-view-controls] { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.dx-schedule [data-schedule-desktop], +.dx-schedule [data-schedule-mobile] { + background: var(--schedule-surface); +} + +.dx-schedule [data-schedule-mobile] { + display: none; +} + +.dx-schedule[data-layout="responsive"] [data-schedule-desktop] { + display: block; +} + +.dx-schedule[data-layout="responsive"] [data-schedule-mobile] { + display: none; +} + +.dx-schedule [data-schedule-view="day"], +.dx-schedule [data-schedule-view="week"] { + display: grid; + min-width: 0; +} + +.dx-schedule [data-schedule-all-day-row] { + position: relative; + display: grid; + border-top: 1px solid var(--schedule-border); + border-bottom: 1px solid var(--schedule-border); + background: color-mix(in srgb, var(--schedule-surface-muted) 82%, var(--schedule-surface)); +} + +.dx-schedule [data-schedule-all-day-column] { + position: relative; + min-width: 0; + min-height: 3.0rem; + border-right: 1px solid var(--schedule-border-soft); +} + +.dx-schedule [data-schedule-all-day-slot] { + position: absolute; + z-index: 0; + display: block; + width: 100%; + min-width: 0; + height: 100%; + min-height: 0; + border: 0; + background: transparent; + color: var(--schedule-muted); + font-size: 0.68rem; + font-weight: 700; + inset: 0; + text-transform: uppercase; +} + +.dx-schedule [data-schedule-all-day-events] { + position: absolute; + z-index: 1; + display: grid; + padding: 4px; + inset: 0; + pointer-events: none; +} + +.dx-schedule [data-schedule-all-day-events] [data-schedule-event] { + pointer-events: auto; +} + +.dx-schedule [data-schedule-time-grid] { + display: grid; + max-height: 42rem; + overflow-y: auto; +} + +.dx-schedule [data-schedule-day-column] { + position: relative; + overflow: hidden; + min-width: 0; + border-right: 1px solid var(--schedule-border); +} + +.dx-schedule [data-schedule-day-slots] { + position: relative; +} + +.dx-schedule [data-schedule-timed-events] { + position: absolute; + z-index: 1; + inset: 0; + pointer-events: none; +} + +.dx-schedule [data-schedule-timed-spanning-events] { + z-index: 2; +} + +.dx-schedule [data-schedule-day-header-row] { + display: grid; +} + +.dx-schedule [data-schedule-day-header]:hover { + background: var(--schedule-surface-emphasis); +} + +.dx-schedule [data-schedule-day-header] { + width: 100%; + padding: 1.25rem; + border-bottom: 1px solid var(--schedule-border); + background: var(--schedule-surface-muted); +} + +.dx-schedule [data-schedule-day-header], +.dx-schedule [data-schedule-month-day-button] { + border: 0; + appearance: none; + background: transparent; + color: var(--schedule-text); + cursor: pointer; + font-size: 0.76rem; + font-weight: 700; + overflow-wrap: anywhere; +} + +.dx-schedule [data-schedule-day-header], +.dx-schedule [data-schedule-month-day-button]:not(:last-of-type) { + border-right: 1px solid var(--schedule-border); +} + +.dx-schedule [data-schedule-time-slot] { + position: relative; + display: grid; + width: 100%; + min-width: 0; + height: var(--schedule-time-slot-size); + min-height: var(--schedule-time-slot-size); + box-sizing: border-box; + align-content: start; + padding: 2px 6px; + border: 0; + border-bottom: 1px solid var(--schedule-border-soft); + background: transparent; + color: var(--schedule-muted); + cursor: pointer; + font-size: 0.72rem; + gap: 4px; + text-align: left; +} + +.dx-schedule [data-schedule-time-slot-label] { + display: block; + color: var(--schedule-muted); + font-size: 0.72rem; + font-weight: 600; +} + +.dx-schedule [data-schedule-time-slot]:hover, +.dx-schedule [data-schedule-month-day]:hover { + background: var(--schedule-selected); +} + +.dx-schedule [data-current-day="true"] { + box-shadow: inset 0 0 0 2px var(--schedule-focus); +} + +.dx-schedule [data-slot-select-enabled="true"]:active { + background: var(--schedule-range); +} + +.dx-schedule [data-selected-range="true"] { + background: var(--schedule-range); + box-shadow: inset 0 0 0 1px var(--schedule-range-border); +} + +.dx-schedule [data-drop-enabled="true"] { + box-shadow: inset 0 0 0 1px transparent; +} + +.dx-schedule:not([data-dragging="true"], [data-resizing="true"]) [data-drop-enabled="true"]:hover { + box-shadow: inset 0 0 0 1px var(--schedule-focus); +} + +.dx-schedule [data-schedule-event] { + position: relative; + display: block; + min-width: 0; + padding: 4px 6px; + border: 1px solid color-mix(in srgb, var(--event-color, #2563eb) 70%, black); + border-radius: 8px; + border-left-width: 4px; + margin: 4px; + background: color-mix(in srgb, var(--event-color, #2563eb) 16%, var(--schedule-surface)); + box-shadow: 0 4px 12px color-mix(in srgb, var(--secondary-color) 10%, transparent); + color: var(--schedule-text); + font-size: 0.8rem; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.dx-schedule [data-schedule-time-slot] [data-schedule-event] { + margin: 2px 0; +} + +.dx-schedule [data-schedule-timed-events] [data-schedule-event], +.dx-schedule [data-schedule-timed-spanning-events] [data-schedule-event] { + position: absolute; + overflow: hidden; + box-sizing: border-box; + margin: 0; + pointer-events: auto; +} + +.dx-schedule [data-schedule-drop-preview] { + position: absolute; + z-index: 0; + box-sizing: border-box; + border: 1px solid var(--schedule-drop-ring); + border-radius: 8px; + background: var(--schedule-drop); + box-shadow: + inset 0 0 0 1px var(--schedule-drop-ring), + 0 8px 18px color-mix(in srgb, var(--schedule-drop-ring) 18%, transparent); + pointer-events: none; +} + +.dx-schedule [data-schedule-event] strong { + display: block; + font-size: 0.82rem; +} + +.dx-schedule [data-schedule-event-time] { + color: var(--schedule-muted); + font-size: 0.72rem; +} + +.dx-schedule [data-schedule-event][data-color="blue"] { --event-color: #2f80ed; } +.dx-schedule [data-schedule-event][data-color="violet"] { --event-color: #7c3aed; } +.dx-schedule [data-schedule-event][data-color="green"] { --event-color: #219653; } +.dx-schedule [data-schedule-event][data-color="orange"] { --event-color: #f2994a; } +.dx-schedule [data-schedule-event][data-color="teal"] { --event-color: #0e9384; } +.dx-schedule [data-schedule-event][data-color="pink"] { --event-color: #db2777; } +.dx-schedule [data-schedule-event][data-color="gray"] { --event-color: #64748b; } +.dx-schedule [data-schedule-event][data-color="amber"] { --event-color: #d97706; } +.dx-schedule [data-schedule-event][data-color="red"] { --event-color: #dc2626; } +.dx-schedule [data-schedule-event][data-color="indigo"] { --event-color: #4f46e5; } + +.dx-schedule [data-schedule-event][data-all-day="true"] { + padding: 5px 6px; + background: color-mix(in srgb, var(--event-color, #2563eb) 24%, var(--schedule-surface)); + font-size: 0.72rem; + font-weight: 700; +} + +.dx-schedule [data-schedule-event][data-all-day="true"] strong, +.dx-schedule [data-schedule-event][data-all-day="true"] [data-schedule-event-time] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dx-schedule [data-schedule-event][data-draggable="true"] { + cursor: grab; +} + +.dx-schedule [data-schedule-event][data-draggable="true"]:active { + background: var(--schedule-drag); + cursor: grabbing; + opacity: 0.78; +} + +.dx-schedule [data-drop-accepted="true"] { + background: transparent; + box-shadow: inset 0 0 0 1px transparent; +} + +.dx-schedule [data-drop-active="true"] { + background: var(--schedule-drop); + box-shadow: + inset 0 0 0 2px var(--schedule-drop-ring), + inset 0 0 0 999px color-mix(in srgb, var(--schedule-drop-ring) 5%, transparent); +} + +.dx-schedule [data-drop-denied="true"] { + background: transparent; + box-shadow: inset 0 0 0 1px transparent; +} + +.dx-schedule [data-schedule-event][data-resizable="true"] { + padding-top: 6px; + padding-bottom: 6px; +} + +.dx-schedule [data-schedule-event][data-drag-disabled="true"], +.dx-schedule [data-schedule-event][data-resize-disabled="true"] { + opacity: 0.72; +} + +.dx-schedule [data-schedule-event][data-disabled="true"] { + filter: saturate(0.82); +} + +.dx-schedule[data-resizing="true"] [data-drop-accepted="true"] { + background: transparent; + box-shadow: inset 0 0 0 1px transparent; +} + +.dx-schedule[data-resizing="true"] [data-drop-active="true"] { + background: var(--schedule-resize-drop); + box-shadow: + inset 0 0 0 2px var(--schedule-resize-ring), + inset 0 0 0 999px color-mix(in srgb, var(--schedule-resize-ring) 5%, transparent); +} + +.dx-schedule[data-resizing="true"] [data-resize-source="true"] { + pointer-events: none; + visibility: hidden; +} + +.dx-schedule [data-schedule-resize-preview] { + position: absolute; + z-index: 2; + top: 2px; + right: 6px; + left: 6px; + height: calc(var(--schedule-time-slot-size) * var(--schedule-resize-preview-slots, 1) - 8px); + border-style: dashed; + background: color-mix(in srgb, var(--event-color, #2563eb) 22%, var(--schedule-surface)); + box-shadow: + 0 8px 18px color-mix(in srgb, var(--event-color, #2563eb) 16%, transparent), + inset 0 0 0 1px var(--schedule-resize-ring); + pointer-events: none; +} + +.dx-schedule [data-drop-active="true"][data-drop-denied="true"] { + background: var(--schedule-drop-denied); + box-shadow: + inset 0 0 0 2px var(--schedule-drop-denied-ring), + inset 0 0 0 999px color-mix(in srgb, var(--schedule-drop-denied-ring) 5%, transparent); + cursor: not-allowed; +} + +.dx-schedule [data-schedule-resize-handle] { + position: absolute; + z-index: 1; + right: 25%; + left: 25%; + overflow: hidden; + height: 4px; + border: 0; + border-radius: 99%; + appearance: none; + background: color-mix(in srgb, var(--event-color, #2563eb) 78%, transparent); + color: transparent; + cursor: ns-resize; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; + visibility: hidden; +} + +.dx-schedule [data-schedule-resize-handle="start"] { + top: -2px; /* button should be slightly off edge */ +} + +.dx-schedule [data-schedule-resize-handle="end"] { + bottom: -2px; /* button should be slightly off edge */ +} + +.dx-schedule [data-schedule-event][data-resizable="true"]:hover [data-schedule-resize-handle], +.dx-schedule [data-schedule-event][data-resizable="true"]:focus-within [data-schedule-resize-handle] { + opacity: 1; + pointer-events: auto; + visibility: visible; +} + +.dx-schedule [data-schedule-event][data-layout-column="1"] { --overlap-offset: 1; } +.dx-schedule [data-schedule-event][data-layout-column="2"] { --overlap-offset: 2; } +.dx-schedule [data-schedule-event][data-layout-column="3"] { --overlap-offset: 3; } + +.dx-schedule [data-schedule-view="month"] { + overflow-x: auto; +} + +.dx-schedule [data-schedule-month-weekdays] { + display: grid; + min-width: 840px; + grid-template-columns: repeat(7, minmax(120px, 1fr)); +} + +.dx-schedule [data-schedule-month-weekday] { + padding: 0.65rem 0.5rem; + border-right: 1px solid var(--schedule-border); + border-bottom: 1px solid var(--schedule-border); + background: var(--schedule-surface-muted); + color: var(--schedule-muted); + font-size: 0.68rem; + font-weight: 700; + text-align: center; + text-transform: uppercase; +} + +.dx-schedule [data-schedule-month-week] { + position: relative; + min-width: 840px; +} + +.dx-schedule [data-schedule-month-week-days], +.dx-schedule [data-schedule-month-week-events] { + display: grid; + grid-template-columns: repeat(7, minmax(120px, 1fr)); +} + +.dx-schedule [data-schedule-month-week-events] { + position: absolute; + z-index: 1; + top: 2rem; + right: 0; + left: 0; + padding: 0 4px; + pointer-events: none; +} + +.dx-schedule [data-schedule-month-week-events] [data-schedule-event] { + overflow: hidden; + margin: 4px 0; + pointer-events: auto; +} + +.dx-schedule [data-schedule-month-day] { + overflow: hidden; + min-height: 118px; + border-right: 1px solid var(--schedule-border); + border-bottom: 1px solid var(--schedule-border); + background: var(--schedule-surface); +} + +.dx-schedule [data-schedule-month-day][data-outside-month="true"] { + background: var(--schedule-surface-muted); + color: var(--schedule-muted); +} + +.dx-schedule [data-schedule-view="year"] { + display: grid; + padding: 14px; + gap: 14px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dx-schedule [data-schedule-year-month] { + display: grid; + min-height: 0; + align-content: start; + padding: 12px; + border: 1px solid var(--schedule-border); + border-radius: 12px; + background: var(--schedule-surface-muted); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--secondary-color) 5%, transparent), + 0 10px 24px color-mix(in srgb, var(--secondary-color) 8%, transparent); + color: var(--schedule-text); + cursor: pointer; + font-weight: 700; + gap: 10px; + text-align: left; +} + +.dx-schedule [data-schedule-year-month]:hover { + background: var(--schedule-selected-strong); + box-shadow: inset 0 0 0 1px var(--schedule-focus); +} + +.dx-schedule [data-schedule-year-month][data-current-month="true"] { + border-color: color-mix(in srgb, var(--schedule-focus) 48%, var(--schedule-border)); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--schedule-focus) 26%, transparent), + 0 10px 24px color-mix(in srgb, var(--secondary-color) 8%, transparent); +} + +.dx-schedule [data-schedule-year-month-header] { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.dx-schedule [data-schedule-year-month-label] { + font-size: 0.95rem; + letter-spacing: -0.01em; +} + +.dx-schedule [data-schedule-year-month-meta] { + color: var(--schedule-muted); + font-size: 0.72rem; + font-weight: 600; +} + +.dx-schedule [data-schedule-year-weekdays], +.dx-schedule [data-schedule-year-days] { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.dx-schedule [data-schedule-year-weekdays] { + gap: 4px; +} + +.dx-schedule [data-schedule-year-weekday] { + color: var(--schedule-muted); + font-size: 0.64rem; + font-weight: 700; + text-align: center; + text-transform: uppercase; +} + +.dx-schedule [data-schedule-year-days] { + gap: 4px; +} + +.dx-schedule [data-schedule-year-day] { + display: grid; + align-content: start; + padding: 0.28rem 0.1rem 0.22rem; + border-radius: 8px; + background: transparent; + justify-items: center; +} + +.dx-schedule [data-schedule-year-day][data-outside-month="true"] { + color: var(--schedule-muted); + opacity: 0.72; +} + +.dx-schedule [data-schedule-year-day][data-weekend="true"]:not([data-outside-month="true"]) [data-schedule-year-day-number] { + color: color-mix(in srgb, var(--secondary-error-color) 75%, var(--schedule-text)); +} + +.dx-schedule [data-schedule-year-day][data-selected-day="true"] { + background: var(--schedule-selected); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--schedule-focus) 26%, transparent); +} + +.dx-schedule [data-schedule-year-day][data-current-day="true"] { + background: var(--schedule-selected-strong); +} + +.dx-schedule [data-schedule-year-day-number] { + font-size: 0.72rem; + font-weight: 700; + line-height: 1.1; +} + +.dx-schedule [data-schedule-year-day-dots] { + display: inline-flex; + min-width: 0; + min-height: 0.56rem; + align-items: center; + justify-content: center; + margin-top: 0.2rem; + gap: 0; +} + +.dx-schedule [data-schedule-event-dot] { + width: 0.34rem; + height: 0.34rem; + border-radius: 999px; + background: var(--event-color, var(--schedule-focus)); + box-shadow: 0 0 0 1px color-mix(in srgb, white 72%, transparent); +} + +.dx-schedule [data-schedule-event-dot] + [data-schedule-event-dot] { + margin-left: -0.08rem; +} + +.dx-schedule [data-schedule-event-dot]:nth-child(even) { + transform: translateY(-0.04rem); +} + +.dx-schedule [data-schedule-event-dot]:nth-child(odd):not(:first-child) { + transform: translateY(0.04rem); +} + +.dx-schedule [data-schedule-event-dot][data-color="blue"] { --event-color: #2f80ed; } +.dx-schedule [data-schedule-event-dot][data-color="violet"] { --event-color: #7c3aed; } +.dx-schedule [data-schedule-event-dot][data-color="green"] { --event-color: #219653; } +.dx-schedule [data-schedule-event-dot][data-color="orange"] { --event-color: #f2994a; } +.dx-schedule [data-schedule-event-dot][data-color="teal"] { --event-color: #0e9384; } +.dx-schedule [data-schedule-event-dot][data-color="pink"] { --event-color: #db2777; } +.dx-schedule [data-schedule-event-dot][data-color="gray"] { --event-color: #64748b; } +.dx-schedule [data-schedule-event-dot][data-color="amber"] { --event-color: #d97706; } +.dx-schedule [data-schedule-event-dot][data-color="red"] { --event-color: #dc2626; } +.dx-schedule [data-schedule-event-dot][data-color="indigo"] { --event-color: #4f46e5; } + +.dx-schedule [data-schedule-year-day-overflow] { + color: var(--schedule-muted); + font-size: 0.58rem; + font-weight: 700; + line-height: 1; +} + +.dx-schedule [data-schedule-event-dot] + [data-schedule-year-day-overflow] { + margin-left: 0.16rem; +} + +@media (width <= 720px) { + .dx-schedule [data-schedule-header] { + flex-direction: column; + align-items: stretch; + } + + .dx-schedule [data-schedule-header-navigation], + .dx-schedule [data-schedule-view-controls] { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .dx-schedule [data-schedule-header-navigation] [data-schedule-date-picker] { + min-width: 0; + grid-column: span 2; + } + + .dx-schedule [data-schedule-header-navigation] [data-schedule-date-picker] .dx-date-picker-group { + width: 100%; + min-width: 0; + } + + .dx-schedule[data-layout="responsive"] [data-schedule-desktop] { + display: none; + } + + .dx-schedule[data-layout="responsive"] [data-schedule-mobile] { + display: block; + } + + .dx-schedule [data-schedule-month-day] { + min-height: 74px; + } + + .dx-schedule [data-schedule-view="year"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (width <= 560px) { + .dx-schedule [data-schedule-view="year"] { + grid-template-columns: 1fr; + } +} diff --git a/preview/src/components/schedule/variants/controlled/mod.rs b/preview/src/components/schedule/variants/controlled/mod.rs new file mode 100644 index 00000000..c6fc9b1d --- /dev/null +++ b/preview/src/components/schedule/variants/controlled/mod.rs @@ -0,0 +1,37 @@ +use super::super::component::*; +use dioxus::prelude::*; +use time::Duration; + +#[component] +pub fn Demo() -> Element { + let mut date = use_signal(sample_date); + let mut view = use_signal(|| ScheduleView::Week); + let mut status = use_signal(|| "Controlled schedule".to_string()); + + rsx! { + div { style: "display: grid; gap: 12px; padding: 20px;", + div { "data-schedule-controlled-status": true, style: "font-size: 0.875rem;", "{status}" } + div { style: "display: flex; flex-wrap: wrap; gap: 8px; align-items: center;", + button { onclick: move |_| date.set(date() - Duration::days(1)), "Previous day" } + button { onclick: move |_| date.set(sample_date()), "Reset date" } + button { onclick: move |_| date.set(date() + Duration::days(1)), "Next day" } + button { onclick: move |_| view.set(ScheduleView::Day), "Day" } + button { onclick: move |_| view.set(ScheduleView::Week), "Week" } + button { onclick: move |_| view.set(ScheduleView::Month), "Month" } + button { onclick: move |_| view.set(ScheduleView::Year), "Year" } + } + Schedule { + date: Some(date()), + view: Some(view()), + on_date_change: move |payload: ScheduleDateChange| { + date.set(payload.next); + status.set(format!("Date changed to {}", payload.next)); + }, + on_view_change: move |payload: ScheduleViewChange| { + view.set(payload.next); + status.set(format!("View changed to {:?}", payload.next)); + }, + } + } + } +} diff --git a/preview/src/components/schedule/variants/custom_event/mod.rs b/preview/src/components/schedule/variants/custom_event/mod.rs new file mode 100644 index 00000000..7c0ee807 --- /dev/null +++ b/preview/src/components/schedule/variants/custom_event/mod.rs @@ -0,0 +1,23 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { style: "padding: 20px;", + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + render_event_body: Callback::new(|context: ScheduleEventRenderContext| { + let event = context.event; + rsx! { + div { "data-schedule-custom-event": event.id.clone(), + strong { "{event.title}" } + span { " · custom body" } + } + } + }), + } + } + } +} diff --git a/preview/src/components/schedule/variants/custom_header/mod.rs b/preview/src/components/schedule/variants/custom_header/mod.rs new file mode 100644 index 00000000..bb406588 --- /dev/null +++ b/preview/src/components/schedule/variants/custom_header/mod.rs @@ -0,0 +1,54 @@ +use super::super::component::*; +use dioxus::prelude::*; +use time::Duration; + +#[component] +pub fn Demo() -> Element { + let mut date = use_signal(sample_date); + let mut view = use_signal(|| ScheduleView::Week); + + rsx! { + div { style: "display: grid; gap: 12px; padding: 20px;", + div { style: "display: flex; flex-wrap: wrap; gap: 8px; align-items: center;", + button { onclick: move |_| date.set(date() - Duration::weeks(1)), "Previous week" } + button { onclick: move |_| date.set(sample_date()), "Today" } + button { onclick: move |_| date.set(date() + Duration::weeks(1)), "Next week" } + button { onclick: move |_| view.set(ScheduleView::Day), "Day" } + button { onclick: move |_| view.set(ScheduleView::Week), "Week" } + button { onclick: move |_| view.set(ScheduleView::Month), "Month" } + } + Schedule { + date: Some(date()), + view: Some(view()), + with_default_header: false, + header: rsx! { + div { + "data-schedule-custom-header": true, + style: "display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--schedule-border); background: var(--schedule-surface-muted);", + strong { "Custom planning header" } + span { "{date} · {view:?}" } + } + }, + on_date_change: move |payload: ScheduleDateChange| date.set(payload.next), + on_view_change: move |payload: ScheduleViewChange| view.set(payload.next), + day_view: ScheduleDayViewConfig { + time_grid: ScheduleTimeGridConfig { + with_default_header: false, + ..workday_time_grid() + }, + }, + week_view: ScheduleWeekViewConfig { + time_grid: ScheduleTimeGridConfig { + with_default_header: false, + ..workday_time_grid() + }, + ..ScheduleWeekViewConfig::default() + }, + month_view: ScheduleMonthViewConfig { + with_default_header: false, + ..ScheduleMonthViewConfig::default() + }, + } + } + } +} diff --git a/preview/src/components/schedule/variants/day/mod.rs b/preview/src/components/schedule/variants/day/mod.rs new file mode 100644 index 00000000..a211c7db --- /dev/null +++ b/preview/src/components/schedule/variants/day/mod.rs @@ -0,0 +1,22 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { style: "padding: 20px;", + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Day, + day_view: ScheduleDayViewConfig { + time_grid: ScheduleTimeGridConfig { + start_hour: 8, + end_hour: 18, + slot_minutes: 30, + with_default_header: true, + }, + }, + } + } + } +} diff --git a/preview/src/components/schedule/variants/drag_and_drop/mod.rs b/preview/src/components/schedule/variants/drag_and_drop/mod.rs new file mode 100644 index 00000000..0a57a025 --- /dev/null +++ b/preview/src/components/schedule/variants/drag_and_drop/mod.rs @@ -0,0 +1,33 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut last_drop = use_signal(|| "Drag an event to a time slot".to_string()); + let mut events = use_signal(sample_events); + + rsx! { + div { style: "display: grid; gap: 12px; padding: 20px;", + div { style: "font-size: 0.875rem;", "{last_drop}" } + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + events: events(), + with_events_drag_and_drop: true, + on_event_drop: move |payload: ScheduleEventDrop| { + events.with_mut(|events| { + apply_demo_event_drop( + events, + &payload, + ScheduleRecurrenceExpansionLimit::default(), + ); + }); + last_drop.set(format!("Dropped {} on {}", payload.event.title, payload.new_start)); + }, + on_external_event_drop: move |payload: ScheduleExternalDrop| { + last_drop.set(format!("External item dropped at {}", payload.start)); + }, + } + } + } +} diff --git a/preview/src/components/schedule/variants/external_drop/mod.rs b/preview/src/components/schedule/variants/external_drop/mod.rs new file mode 100644 index 00000000..7406c78e --- /dev/null +++ b/preview/src/components/schedule/variants/external_drop/mod.rs @@ -0,0 +1,33 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut message = use_signal(|| "Drag the external task into a time slot".to_string()); + + rsx! { + div { style: "display: grid; gap: 12px; padding: 20px;", + div { + draggable: true, + "data-schedule-external-source": true, + style: "width: max-content; border: 1px dashed currentColor; border-radius: 8px; padding: 8px 10px; cursor: grab;", + ondragstart: move |event: Event| { + event.data_transfer().set_effect_allowed("copy"); + event.data_transfer().set_drop_effect("copy"); + let _ = event.data_transfer().set_data("text/plain", "External planning task"); + }, + "External planning task" + } + div { "data-schedule-external-drop-status": true, style: "font-size: 0.875rem;", "{message}" } + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + with_events_drag_and_drop: true, + on_external_event_drop: move |payload: ScheduleExternalDrop| { + let data = payload.data.unwrap_or_else(|| "external item".to_string()); + message.set(format!("Dropped {data} at {}", payload.start)); + }, + } + } + } +} diff --git a/preview/src/components/schedule/variants/internationalized/mod.rs b/preview/src/components/schedule/variants/internationalized/mod.rs new file mode 100644 index 00000000..daf8f43e --- /dev/null +++ b/preview/src/components/schedule/variants/internationalized/mod.rs @@ -0,0 +1,25 @@ +use super::super::component::*; +use dioxus::prelude::*; +use time::Weekday; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { style: "padding: 20px;", + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Month, + locale: "fr-FR", + labels: french_labels(), + week_view: ScheduleWeekViewConfig { + first_day_of_week: Weekday::Monday, + time_grid: workday_time_grid(), + }, + month_view: ScheduleMonthViewConfig { + first_day_of_week: Weekday::Monday, + ..ScheduleMonthViewConfig::default() + }, + } + } + } +} diff --git a/preview/src/components/schedule/variants/main/mod.rs b/preview/src/components/schedule/variants/main/mod.rs new file mode 100644 index 00000000..a87a97fe --- /dev/null +++ b/preview/src/components/schedule/variants/main/mod.rs @@ -0,0 +1,54 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut status = use_signal(|| "Interact with the schedule".to_string()); + let mut events = use_signal(sample_events); + + rsx! { + div { style: "display: grid; gap: 12px; padding: 20px;", + div { "data-schedule-main-status": true, style: "font-size: 0.875rem;", "{status}" } + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + events: events(), + with_events_drag_and_drop: true, + with_drag_slot_select: true, + with_event_resize: true, + on_event_click: move |payload: ScheduleEventClick| { + status.set(format!("Clicked event {}", payload.event.title)); + }, + on_time_slot_click: move |payload: ScheduleTimeSlotClick| { + status.set(format!("Clicked time slot {}", payload.start)); + }, + on_all_day_slot_click: move |payload: ScheduleAllDaySlotClick| { + status.set(format!("Clicked all-day slot {}", payload.date)); + }, + on_day_click: move |payload: ScheduleDayClick| { + status.set(format!("Clicked day {}", payload.date)); + }, + on_event_drop: move |payload: ScheduleEventDrop| { + events.with_mut(|events| { + apply_demo_event_drop( + events, + &payload, + ScheduleRecurrenceExpansionLimit::default(), + ); + }); + status.set(format!("Dropped {} on {}", payload.event.title, payload.new_start)); + }, + on_event_resize: move |payload: ScheduleEventResize| { + events.with_mut(|events| { + apply_demo_event_resize( + events, + &payload, + ScheduleRecurrenceExpansionLimit::default(), + ); + }); + status.set(format!("Resized {} to {}", payload.event.title, payload.new_end)); + }, + } + } + } +} diff --git a/preview/src/components/schedule/variants/month/mod.rs b/preview/src/components/schedule/variants/month/mod.rs new file mode 100644 index 00000000..547db3e8 --- /dev/null +++ b/preview/src/components/schedule/variants/month/mod.rs @@ -0,0 +1,14 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { style: "padding: 20px;", + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Month, + } + } + } +} diff --git a/preview/src/components/schedule/variants/multi_view/mod.rs b/preview/src/components/schedule/variants/multi_view/mod.rs new file mode 100644 index 00000000..dd5e17d5 --- /dev/null +++ b/preview/src/components/schedule/variants/multi_view/mod.rs @@ -0,0 +1,27 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let events = sample_events(); + + rsx! { + div { style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; padding: 20px;", + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Day, + events: events.clone(), + } + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Month, + events: events.clone(), + } + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Year, + events, + } + } + } +} diff --git a/preview/src/components/schedule/variants/recurring/mod.rs b/preview/src/components/schedule/variants/recurring/mod.rs new file mode 100644 index 00000000..e214a7ea --- /dev/null +++ b/preview/src/components/schedule/variants/recurring/mod.rs @@ -0,0 +1,16 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { style: "padding: 20px;", + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + events: sample_events(), + recurrence_expansion_limit: ScheduleRecurrenceExpansionLimit { max_occurrences: 8 }, + } + } + } +} diff --git a/preview/src/components/schedule/variants/resize/mod.rs b/preview/src/components/schedule/variants/resize/mod.rs new file mode 100644 index 00000000..29aaee5c --- /dev/null +++ b/preview/src/components/schedule/variants/resize/mod.rs @@ -0,0 +1,30 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut last_resize = use_signal(|| "Use an event resize handle".to_string()); + let mut events = use_signal(sample_events); + + rsx! { + div { style: "display: grid; gap: 12px; padding: 20px;", + div { style: "font-size: 0.875rem;", "{last_resize}" } + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + events: events(), + with_event_resize: true, + on_event_resize: move |payload: ScheduleEventResize| { + events.with_mut(|events| { + apply_demo_event_resize( + events, + &payload, + ScheduleRecurrenceExpansionLimit::default(), + ); + }); + last_resize.set(format!("Resized {} to {}", payload.event.title, payload.new_end)); + }, + } + } + } +} diff --git a/preview/src/components/schedule/variants/responsive/mod.rs b/preview/src/components/schedule/variants/responsive/mod.rs new file mode 100644 index 00000000..7874b996 --- /dev/null +++ b/preview/src/components/schedule/variants/responsive/mod.rs @@ -0,0 +1,17 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { style: "padding: 20px;", + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + layout: ScheduleLayout::Responsive, + with_events_drag_and_drop: true, + with_drag_slot_select: true, + } + } + } +} diff --git a/preview/src/components/schedule/variants/slot_selection/mod.rs b/preview/src/components/schedule/variants/slot_selection/mod.rs new file mode 100644 index 00000000..acefc1d2 --- /dev/null +++ b/preview/src/components/schedule/variants/slot_selection/mod.rs @@ -0,0 +1,43 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut selected = use_signal(|| "Click or drag an empty slot".to_string()); + let mut events = use_signal(sample_events); + + rsx! { + div { style: "display: grid; gap: 12px; padding: 20px;", + div { style: "font-size: 0.875rem;", "{selected}" } + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + events: events(), + with_drag_slot_select: true, + on_event_create: move |payload: ScheduleEventCreate| { + events.with_mut(|events| { + let index = events.len() + 1; + events.push(ScheduleEvent { + id: format!("created-{index}"), + title: match payload.source { + ScheduleEventCreateSource::TimeSlotClick => "Created from click".to_string(), + ScheduleEventCreateSource::TimeSlotDrag => "Created from drag".to_string(), + ScheduleEventCreateSource::AllDaySlotClick => "Created all-day".to_string(), + ScheduleEventCreateSource::DayClick => "Created from day".to_string(), + }, + start: payload.start, + end: payload.end, + all_day: payload.all_day, + color: Some("blue".to_string()), + description: None, + recurrence: None, + drag_disabled: false, + resize_disabled: false, + }); + }); + selected.set(format!("Created {} to {}", payload.start, payload.end)); + }, + } + } + } +} diff --git a/preview/src/components/schedule/variants/static/mod.rs b/preview/src/components/schedule/variants/static/mod.rs new file mode 100644 index 00000000..5789c5c0 --- /dev/null +++ b/preview/src/components/schedule/variants/static/mod.rs @@ -0,0 +1,18 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { style: "padding: 20px;", + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + mode: ScheduleMode::Static, + with_events_drag_and_drop: true, + with_event_resize: true, + with_drag_slot_select: true, + } + } + } +} diff --git a/preview/src/components/schedule/variants/week/mod.rs b/preview/src/components/schedule/variants/week/mod.rs new file mode 100644 index 00000000..e8812d07 --- /dev/null +++ b/preview/src/components/schedule/variants/week/mod.rs @@ -0,0 +1,23 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { style: "padding: 20px;", + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Week, + week_view: ScheduleWeekViewConfig { + time_grid: ScheduleTimeGridConfig { + start_hour: 7, + end_hour: 18, + slot_minutes: 30, + with_default_header: true, + }, + ..ScheduleWeekViewConfig::default() + }, + } + } + } +} diff --git a/preview/src/components/schedule/variants/year/mod.rs b/preview/src/components/schedule/variants/year/mod.rs new file mode 100644 index 00000000..869bb4d0 --- /dev/null +++ b/preview/src/components/schedule/variants/year/mod.rs @@ -0,0 +1,14 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { style: "padding: 20px;", + Schedule { + default_date: sample_date(), + default_view: ScheduleView::Year, + } + } + } +} diff --git a/preview/src/components/split_pane/component.json b/preview/src/components/split_pane/component.json new file mode 100644 index 00000000..29eed60a --- /dev/null +++ b/preview/src/components/split_pane/component.json @@ -0,0 +1,13 @@ +{ + "name": "split_pane", + "description": "A resizable split-pane layout component.", + "authors": ["Dioxus Labs"], + "exclude": ["variants", "docs.md", "component.json"], + "cargoDependencies": [ + { + "name": "dioxus-primitives", + "git": "https://github.com/DioxusLabs/components" + } + ], + "globalAssets": ["../../../assets/dx-components-theme.css"] +} diff --git a/preview/src/components/split_pane/component.rs b/preview/src/components/split_pane/component.rs new file mode 100644 index 00000000..58e28dbc --- /dev/null +++ b/preview/src/components/split_pane/component.rs @@ -0,0 +1,226 @@ +use dioxus::prelude::*; +use dioxus_primitives::split_pane::{self, PaneProps, SplitPaneDividerProps, SplitPaneProps}; +use dioxus_primitives::{dioxus_attributes::attributes, merge_attributes}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +pub use dioxus_primitives::split_pane::{ + use_split_pane_persistence, SplitPaneDirection, SplitPaneResizeEvent, SplitPaneSize, + SplitPaneStorage, +}; + +#[css_module("/src/components/split_pane/style.css")] +struct Styles; + +const DEFAULT_DIVIDER_SIZE: f64 = 6.0; +static SPLIT_PANE_ID: AtomicUsize = AtomicUsize::new(0); + +/// Styled wrapper around the primitive split pane root. +#[component] +pub fn SplitPane(props: SplitPaneProps) -> Element { + let measurement_id = use_hook(|| SPLIT_PANE_ID.fetch_add(1, Ordering::Relaxed)); + let mut measured_divider_size = use_signal(|| DEFAULT_DIVIDER_SIZE); + let base = attributes!(div { + class: Styles::dx_split_pane, + "data-split-pane-id": "{measurement_id}" + }); + let merged = merge_attributes(vec![base, props.attributes]); + let divider_class = (props.divider_class)() + .or_else(|| Some(Styles::dx_split_pane_divider.to_string())); + let divider_size = use_memo(move || { + let size = (props.divider_size)(); + if size > 0.0 { + size + } else { + measured_divider_size().max(0.0) + } + }); + let divider_style = use_memo(move || { + let style = (props.divider_style)(); + if (props.divider_size)() > 0.0 { + style + } else { + let inferred_style = "flex:0 0 var(--split-pane-divider-size);"; + Some(match style { + Some(style) => format!("{inferred_style}{style}"), + None => inferred_style.to_string(), + }) + } + }); + + use_effect(move || { + spawn(async move { + let script = format!( + r#" + const id = "{measurement_id}"; + const key = `__dxSplitPaneDividerSize_${{id}}`; + + if (window[key]?.cleanup) {{ + window[key].cleanup(); + }} + + const root = document.querySelector(`[data-split-pane-id="${{id}}"]`); + if (!root) {{ + dioxus.send(null); + return; + }} + + let frame = 0; + window[key] = {{ + divider: null, + cleanup: null + }}; + + function parsePx(value) {{ + const size = Number.parseFloat(value); + return Number.isFinite(size) ? size : 0; + }} + + function currentDivider() {{ + return root.querySelector('[role="separator"]'); + }} + + function observeDivider() {{ + const divider = currentDivider(); + if (divider && window[key]?.divider !== divider) {{ + if (window[key]?.divider) {{ + resizeObserver.unobserve(window[key].divider); + }} + resizeObserver.observe(divider); + window[key].divider = divider; + }} + return divider; + }} + + function dividerSize() {{ + const divider = observeDivider(); + if (!divider) {{ + return 0; + }} + + const axis = root.dataset.orientation === "vertical" ? "height" : "width"; + const rect = divider.getBoundingClientRect(); + const measured = axis === "width" ? rect.width : rect.height; + if (measured > 0) {{ + return measured; + }} + + const dividerStyles = getComputedStyle(divider); + const flexBasis = parsePx(dividerStyles.flexBasis); + if (flexBasis > 0) {{ + return flexBasis; + }} + + return parsePx(getComputedStyle(root).getPropertyValue("--split-pane-divider-size")); + }} + + function publish() {{ + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => {{ + const size = dividerSize(); + if (size > 0) {{ + dioxus.send(size); + }} + }}); + }} + + const resizeObserver = new ResizeObserver(publish); + resizeObserver.observe(root); + + const mutationObserver = new MutationObserver(publish); + mutationObserver.observe(root, {{ + attributes: true, + childList: true, + subtree: true, + attributeFilter: ["class", "style", "data-orientation"] + }}); + mutationObserver.observe(document.documentElement, {{ + attributes: true, + attributeFilter: ["class", "style", "data-theme"] + }}); + if (document.body) {{ + mutationObserver.observe(document.body, {{ + attributes: true, + attributeFilter: ["class", "style", "data-theme"] + }}); + }} + + window[key].cleanup = function() {{ + cancelAnimationFrame(frame); + resizeObserver.disconnect(); + mutationObserver.disconnect(); + delete window[key]; + }}; + + publish(); + "# + ); + let mut eval = document::eval(&script); + + while let Ok(Some(size)) = eval.recv::>().await { + measured_divider_size.set(size); + } + }); + }); + + use_drop(move || { + _ = document::eval(&format!( + r#" + window.__dxSplitPaneDividerSize_{measurement_id}?.cleanup?.(); + "# + )); + }); + + rsx! { + split_pane::SplitPane { + direction: props.direction, + resizable: props.resizable, + snap_points: props.snap_points, + snap_tolerance: props.snap_tolerance, + step: props.step, + divider_size, + divider_class, + divider_style, + on_resize_start: props.on_resize_start, + on_resize: props.on_resize, + on_resize_end: props.on_resize_end, + attributes: merged, + {props.children} + } + } +} + +/// Styled split pane child pane. +#[component] +pub fn Pane(props: PaneProps) -> Element { + let base = attributes!(div { + class: Styles::dx_split_pane_pane + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + split_pane::Pane { + size: props.size, + default_size: props.default_size, + min_size: props.min_size, + max_size: props.max_size, + attributes: merged, + {props.children} + } + } +} + +/// Styled focusable divider for resizing adjacent panes. +#[component] +pub fn SplitPaneDivider(props: SplitPaneDividerProps) -> Element { + rsx! { + split_pane::SplitPaneDivider { + index: props.index, + class: props.class, + style: props.style, + divider: props.divider, + attributes: props.attributes, + span { class: Styles::dx_split_pane_divider_handle } + {props.children} + } + } +} diff --git a/preview/src/components/split_pane/docs.md b/preview/src/components/split_pane/docs.md new file mode 100644 index 00000000..83bc4004 --- /dev/null +++ b/preview/src/components/split_pane/docs.md @@ -0,0 +1,84 @@ +The SplitPane component creates resizable pane layouts with accessible pointer and keyboard dividers. Use it for editors, dashboards, file browsers, and other views where users need to allocate space between panels. + +## Component Structure + +```rust +SplitPane { + direction: SplitPaneDirection::Horizontal, + step: 24.0, + + Pane { + default_size: SplitPaneSize::percent(35.0), + min_size: SplitPaneSize::px(160.0), + "Left pane" + } + SplitPaneDivider {} + Pane { + min_size: SplitPaneSize::px(220.0), + "Right pane" + } +} +``` + +The root `SplitPane` container must resolve to a concrete width for `Horizontal` layouts or a concrete height for `Vertical` layouts. In practice, give the parent or the `SplitPane` itself an explicit size such as `width: 100%` inside a sized flex row, `height: 320px`, or `height: 100%` inside a parent with its own resolved height. + +## Sizing + +Use `default_size` for uncontrolled panes and `size` for controlled panes. `SplitPaneSize::px` and `SplitPaneSize::percent` can be mixed with `min_size` and `max_size` constraints. + +```rust +let mut sidebar = use_signal(|| Some(SplitPaneSize::percent(30.0))); + +Pane { + size: sidebar, + min_size: SplitPaneSize::px(180.0), + max_size: SplitPaneSize::percent(50.0), + "Controlled sidebar" +} +``` + +## Interaction + +Dividers support pointer dragging, arrow keys, Home, End, Escape, and focus styles. Set `step` to control keyboard resize increments. Use `snap_points` and `snap_tolerance` to make a divider lock to common sizes. + +```rust +SplitPane { + snap_points: vec![SplitPaneSize::percent(25.0), SplitPaneSize::percent(50.0)], + snap_tolerance: 18.0, + step: 16.0, +} +``` + +Add more than two panes by alternating `Pane` and `SplitPaneDivider` children: + +```rust +SplitPane { + direction: SplitPaneDirection::Horizontal, + Pane { default_size: SplitPaneSize::percent(22.0), "Nav" } + SplitPaneDivider {} + Pane { default_size: SplitPaneSize::percent(48.0), "Editor" } + SplitPaneDivider {} + Pane { min_size: SplitPaneSize::px(220.0), "Inspector" } +} +``` + +## Persistence + +`use_split_pane_persistence` returns restored sizes and an `on_resize_end` callback that stores final pane sizes in browser storage. + +```rust +let (stored_sizes, persist_sizes) = + use_split_pane_persistence("workspace-layout", SplitPaneStorage::Local); + +SplitPane { + on_resize_end: persist_sizes, + Pane { + default_size: stored_sizes().and_then(|sizes| sizes.get(0).cloned()), + "Restored pane" + } +} +``` + +## Styling + +Pass `divider_class`, `divider_style`, or per-divider `class` and `style` values to customize handles. `Horizontal` layouts use vertical grab handles with `col-resize` semantics, while `Vertical` layouts use horizontal grab handles with `row-resize` semantics. Pane and root attributes are forwarded to the primitive, so layout-specific classes and labels can be supplied where needed. diff --git a/preview/src/components/split_pane/mod.rs b/preview/src/components/split_pane/mod.rs new file mode 100644 index 00000000..2590c013 --- /dev/null +++ b/preview/src/components/split_pane/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/preview/src/components/split_pane/style.css b/preview/src/components/split_pane/style.css new file mode 100644 index 00000000..1068dfe9 --- /dev/null +++ b/preview/src/components/split_pane/style.css @@ -0,0 +1,70 @@ +.dx-split-pane { + --split-pane-divider-size: 6px; + + overflow: hidden; + box-sizing: border-box; + border: 1px solid var(--primary-color-6); + border-radius: 0.5rem; + background: var(--primary-color-1); +} + +.dx-split-pane-pane { + box-sizing: border-box; + color: var(--primary-color-12); +} + +.dx-split-pane-divider { + position: relative; + z-index: 1; + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + background: var(--primary-color-4); + color: var(--primary-color-11); + outline: none; + touch-action: none; + transition: + background-color 120ms ease, + box-shadow 120ms ease; +} + +.dx-split-pane-divider[data-orientation="horizontal"] { + height: 100%; + cursor: col-resize; +} + +.dx-split-pane-divider[data-orientation="vertical"] { + width: 100%; + cursor: row-resize; +} + +.dx-split-pane-divider:hover, +.dx-split-pane-divider:focus-visible, +.dx-split-pane-divider[data-dragging="true"] { + background: var(--secondary-color-2); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--secondary-color-2) 30%, transparent); +} + +.dx-split-pane-divider[data-resizable="false"] { + cursor: default; + opacity: 0.6; +} + +.dx-split-pane-divider-handle { + display: block; + border-radius: 9999px; + background: currentcolor; + opacity: 0.33; + pointer-events: none; +} + +.dx-split-pane-divider[data-orientation="horizontal"] .dx-split-pane-divider-handle { + width: 33%; + height: 2rem; +} + +.dx-split-pane-divider[data-orientation="vertical"] .dx-split-pane-divider-handle { + width: 2rem; + height: 0.25rem; +} diff --git a/preview/src/components/split_pane/variants/constraints/mod.rs b/preview/src/components/split_pane/variants/constraints/mod.rs new file mode 100644 index 00000000..ad32629b --- /dev/null +++ b/preview/src/components/split_pane/variants/constraints/mod.rs @@ -0,0 +1,36 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { + style: "height: 260px; width: 100%; max-width: 760px;", + SplitPane { + direction: SplitPaneDirection::Horizontal, + Pane { + default_size: SplitPaneSize::px(220.0), + min_size: SplitPaneSize::px(160.0), + max_size: SplitPaneSize::px(320.0), + Panel { title: "Fixed Range", body: "This pane stays between 160px and 320px." } + } + SplitPaneDivider {} + Pane { + min_size: SplitPaneSize::percent(30.0), + Panel { title: "Flexible", body: "The neighboring pane absorbs the remaining width." } + } + } + } + } +} + +#[component] +fn Panel(title: &'static str, body: &'static str) -> Element { + rsx! { + div { + style: "height: 100%; box-sizing: border-box; padding: 1rem;", + h3 { style: "margin: 0 0 0.5rem; font-size: 1rem;", "{title}" } + p { style: "margin: 0; color: var(--primary-color-11);", "{body}" } + } + } +} diff --git a/preview/src/components/split_pane/variants/controlled/mod.rs b/preview/src/components/split_pane/variants/controlled/mod.rs new file mode 100644 index 00000000..6b3af7f1 --- /dev/null +++ b/preview/src/components/split_pane/variants/controlled/mod.rs @@ -0,0 +1,83 @@ +use super::super::component::*; +use crate::components::slider::Slider; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut pane_size = use_signal(|| Some(SplitPaneSize::percent(40.0))); + let mut slider_value = use_signal(|| Some(40.0_f64)); + let formatted_value = use_memo(move || slider_value().unwrap_or(40.0)); + + rsx! { + div { + style: "display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 760px;", + div { + style: "display: flex; align-items: center; gap: 1rem;", + span { style: "min-width: 8rem; font-weight: 600;", "Sidebar {formatted_value:.0}%" } + Slider { + label: "Controlled pane size", + horizontal: true, + min: 20.0, + max: 70.0, + step: 1.0, + value: slider_value, + on_value_change: move |value: f64| { + slider_value.set(Some(value)); + pane_size.set(Some(SplitPaneSize::percent(value))); + }, + } + } + div { + style: "height: 260px;", + SplitPane { + direction: SplitPaneDirection::Horizontal, + on_resize: move |event: SplitPaneResizeEvent| { + if let Some(size) = event.sizes.first().cloned() { + if let Some(value) = first_pane_percent(&event.sizes) { + let clamped = value.clamp(20.0, 70.0); + slider_value.set(Some(clamped)); + pane_size.set(Some(SplitPaneSize::percent(clamped))); + } else { + pane_size.set(Some(size)); + } + } + }, + Pane { + size: pane_size, + min_size: SplitPaneSize::percent(20.0), + max_size: SplitPaneSize::percent(70.0), + Panel { title: "Controlled", body: "The first pane is driven by a signal shared with the slider." } + } + SplitPaneDivider {} + Pane { + min_size: SplitPaneSize::px(180.0), + Panel { title: "Remaining", body: "Dragging the divider updates the same controlled signal." } + } + } + } + } + } +} + +fn first_pane_percent(sizes: &[SplitPaneSize]) -> Option { + let total = sizes.iter().map(size_value).sum::(); + let first = size_value(sizes.first()?); + (total > 0.0).then_some(first / total * 100.0) +} + +fn size_value(size: &SplitPaneSize) -> f64 { + match size { + SplitPaneSize::Px(value) | SplitPaneSize::Percent(value) => *value, + } +} + +#[component] +fn Panel(title: &'static str, body: &'static str) -> Element { + rsx! { + div { + style: "height: 100%; box-sizing: border-box; padding: 1rem;", + h3 { style: "margin: 0 0 0.5rem; font-size: 1rem;", "{title}" } + p { style: "margin: 0; color: var(--primary-color-11);", "{body}" } + } + } +} diff --git a/preview/src/components/split_pane/variants/custom_divider/mod.rs b/preview/src/components/split_pane/variants/custom_divider/mod.rs new file mode 100644 index 00000000..6e9fa7a5 --- /dev/null +++ b/preview/src/components/split_pane/variants/custom_divider/mod.rs @@ -0,0 +1,44 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { + style: "height: 260px; width: 100%; max-width: 760px;", + SplitPane { + direction: SplitPaneDirection::Horizontal, + divider_size: 18.0, + divider_class: "split-pane-demo-divider", + divider_style: "background: color-mix(in oklab, var(--secondary-color-2) 18%, var(--primary-color-3));", + Pane { + default_size: SplitPaneSize::percent(45.0), + min_size: SplitPaneSize::px(180.0), + Panel { title: "Custom Hook", body: "Root-level divider_class and divider_style style all dividers." } + } + SplitPaneDivider { + divider: rsx! { + span { + style: "width: 0.375rem; height: 3rem; border-radius: 9999px; background: var(--secondary-color-2); box-shadow: 0 0 0 1px var(--primary-color-1);" + } + }, + } + Pane { + min_size: SplitPaneSize::px(180.0), + Panel { title: "Custom Content", body: "Divider content can be replaced per divider." } + } + } + } + } +} + +#[component] +fn Panel(title: &'static str, body: &'static str) -> Element { + rsx! { + div { + style: "height: 100%; box-sizing: border-box; padding: 1rem;", + h3 { style: "margin: 0 0 0.5rem; font-size: 1rem;", "{title}" } + p { style: "margin: 0; color: var(--primary-color-11);", "{body}" } + } + } +} diff --git a/preview/src/components/split_pane/variants/main/mod.rs b/preview/src/components/split_pane/variants/main/mod.rs new file mode 100644 index 00000000..8596749a --- /dev/null +++ b/preview/src/components/split_pane/variants/main/mod.rs @@ -0,0 +1,54 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut last_size = use_signal(|| "Drag the divider or focus it and use arrow keys".to_string()); + + rsx! { + div { + style: "display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 760px;", + div { style: "font-size: 0.875rem; color: var(--primary-color-11);", "{last_size}" } + div { + style: "height: 260px;", + SplitPane { + direction: SplitPaneDirection::Horizontal, + step: 24.0, + on_resize: move |event: SplitPaneResizeEvent| { + if let Some(size) = event.sizes.first() { + last_size.set(format!("Left pane: {}", format_size(size))); + } + }, + Pane { + default_size: SplitPaneSize::percent(35.0), + min_size: SplitPaneSize::px(160.0), + DemoPanel { title: "Navigator", body: "Project tree, filters, and quick actions." } + } + SplitPaneDivider {} + Pane { + min_size: SplitPaneSize::px(220.0), + DemoPanel { title: "Preview", body: "Resizable content region using pointer and keyboard input." } + } + } + } + } + } +} + +#[component] +fn DemoPanel(title: &'static str, body: &'static str) -> Element { + rsx! { + section { + style: "height: 100%; box-sizing: border-box; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;", + h3 { style: "margin: 0; font-size: 1rem;", "{title}" } + p { style: "margin: 0; color: var(--primary-color-11); line-height: 1.4;", "{body}" } + } + } +} + +fn format_size(size: &SplitPaneSize) -> String { + match size { + SplitPaneSize::Px(px) => format!("{px:.0}px"), + SplitPaneSize::Percent(percent) => format!("{percent:.0}%"), + } +} diff --git a/preview/src/components/split_pane/variants/multi_pane/mod.rs b/preview/src/components/split_pane/variants/multi_pane/mod.rs new file mode 100644 index 00000000..1f977303 --- /dev/null +++ b/preview/src/components/split_pane/variants/multi_pane/mod.rs @@ -0,0 +1,57 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { + style: "display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 900px;", + div { + style: "font-size: 0.875rem; color: var(--primary-color-11);", + "Three panes share one horizontal SplitPane with two independently focusable dividers." + } + div { + style: "height: 300px;", + SplitPane { + direction: SplitPaneDirection::Horizontal, + Pane { + default_size: SplitPaneSize::percent(22.0), + min_size: SplitPaneSize::px(140.0), + Panel { + title: "Navigator", + body: "A narrow primary pane for folders, filters, or project sections.", + } + } + SplitPaneDivider {} + Pane { + default_size: SplitPaneSize::percent(48.0), + min_size: SplitPaneSize::px(220.0), + Panel { + title: "Editor", + body: "The center pane keeps most of the workspace while both sides remain resizable.", + } + } + SplitPaneDivider {} + Pane { + min_size: SplitPaneSize::px(180.0), + Panel { + title: "Inspector", + body: "Additional panes work by alternating Pane and SplitPaneDivider children.", + } + } + } + } + } + } +} + +#[component] +fn Panel(title: &'static str, body: &'static str) -> Element { + rsx! { + div { + style: "height: 100%; box-sizing: border-box; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;", + h3 { style: "margin: 0; font-size: 1rem;", "{title}" } + p { style: "margin: 0; color: var(--primary-color-11); line-height: 1.4;", "{body}" } + } + } +} diff --git a/preview/src/components/split_pane/variants/nested/mod.rs b/preview/src/components/split_pane/variants/nested/mod.rs new file mode 100644 index 00000000..1ba2e7cb --- /dev/null +++ b/preview/src/components/split_pane/variants/nested/mod.rs @@ -0,0 +1,47 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { + style: "height: 360px; width: 100%; max-width: 820px;", + SplitPane { + direction: SplitPaneDirection::Horizontal, + Pane { + default_size: SplitPaneSize::percent(32.0), + min_size: SplitPaneSize::px(180.0), + Panel { title: "Files", body: "A primary horizontal split." } + } + SplitPaneDivider {} + Pane { + min_size: SplitPaneSize::px(260.0), + SplitPane { + direction: SplitPaneDirection::Vertical, + Pane { + default_size: SplitPaneSize::percent(62.0), + min_size: SplitPaneSize::px(150.0), + Panel { title: "Editor", body: "Nested panes can compose inside a parent pane." } + } + SplitPaneDivider {} + Pane { + min_size: SplitPaneSize::px(90.0), + Panel { title: "Console", body: "Each split owns its own direction, constraints, and dividers." } + } + } + } + } + } + } +} + +#[component] +fn Panel(title: &'static str, body: &'static str) -> Element { + rsx! { + div { + style: "height: 100%; box-sizing: border-box; padding: 1rem;", + h3 { style: "margin: 0 0 0.5rem; font-size: 1rem;", "{title}" } + p { style: "margin: 0; color: var(--primary-color-11);", "{body}" } + } + } +} diff --git a/preview/src/components/split_pane/variants/persistence/mod.rs b/preview/src/components/split_pane/variants/persistence/mod.rs new file mode 100644 index 00000000..6bf8d223 --- /dev/null +++ b/preview/src/components/split_pane/variants/persistence/mod.rs @@ -0,0 +1,46 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let (stored_sizes, persist_sizes) = + use_split_pane_persistence("dioxus-preview-split-pane", SplitPaneStorage::Local); + let first_size = use_memo(move || stored_sizes().and_then(|sizes| sizes.first().cloned())); + let second_size = use_memo(move || stored_sizes().and_then(|sizes| sizes.get(1).cloned())); + + rsx! { + div { + style: "display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 760px;", + div { style: "font-size: 0.875rem; color: var(--primary-color-11);", "Resize, release, and reload the preview to restore the layout." } + div { + style: "height: 260px;", + SplitPane { + direction: SplitPaneDirection::Horizontal, + on_resize_end: persist_sizes, + Pane { + default_size: first_size().or(Some(SplitPaneSize::percent(33.0))), + min_size: SplitPaneSize::px(160.0), + Panel { title: "Persisted A", body: "The persistence hook restores saved browser storage sizes." } + } + SplitPaneDivider {} + Pane { + default_size: second_size().or(Some(SplitPaneSize::percent(67.0))), + min_size: SplitPaneSize::px(180.0), + Panel { title: "Persisted B", body: "The resize-end callback stores final pane sizes." } + } + } + } + } + } +} + +#[component] +fn Panel(title: &'static str, body: &'static str) -> Element { + rsx! { + div { + style: "height: 100%; box-sizing: border-box; padding: 1rem;", + h3 { style: "margin: 0 0 0.5rem; font-size: 1rem;", "{title}" } + p { style: "margin: 0; color: var(--primary-color-11);", "{body}" } + } + } +} diff --git a/preview/src/components/split_pane/variants/snap/mod.rs b/preview/src/components/split_pane/variants/snap/mod.rs new file mode 100644 index 00000000..6e9c738f --- /dev/null +++ b/preview/src/components/split_pane/variants/snap/mod.rs @@ -0,0 +1,60 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut status = use_signal(|| "Snap points: 25%, 50%, 75%".to_string()); + + rsx! { + div { + style: "display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 760px;", + div { style: "font-size: 0.875rem; color: var(--primary-color-11);", "{status}" } + div { + style: "height: 260px;", + SplitPane { + direction: SplitPaneDirection::Horizontal, + snap_points: vec![ + SplitPaneSize::percent(25.0), + SplitPaneSize::percent(50.0), + SplitPaneSize::percent(75.0), + ], + snap_tolerance: 24.0, + step: 12.0, + on_resize_end: move |event: SplitPaneResizeEvent| { + if let Some(size) = event.sizes.first() { + status.set(format!("Released at {}", format_size(size))); + } + }, + Pane { + default_size: SplitPaneSize::percent(50.0), + min_size: SplitPaneSize::percent(15.0), + Panel { title: "Snapping", body: "Resize near common percentages to snap into place." } + } + SplitPaneDivider {} + Pane { + min_size: SplitPaneSize::percent(15.0), + Panel { title: "Workspace", body: "Snap behavior applies to pointer and keyboard resizing." } + } + } + } + } + } +} + +#[component] +fn Panel(title: &'static str, body: &'static str) -> Element { + rsx! { + div { + style: "height: 100%; box-sizing: border-box; padding: 1rem;", + h3 { style: "margin: 0 0 0.5rem; font-size: 1rem;", "{title}" } + p { style: "margin: 0; color: var(--primary-color-11);", "{body}" } + } + } +} + +fn format_size(size: &SplitPaneSize) -> String { + match size { + SplitPaneSize::Px(px) => format!("{px:.0}px"), + SplitPaneSize::Percent(percent) => format!("{percent:.0}%"), + } +} diff --git a/preview/src/components/split_pane/variants/vertical/mod.rs b/preview/src/components/split_pane/variants/vertical/mod.rs new file mode 100644 index 00000000..34b29494 --- /dev/null +++ b/preview/src/components/split_pane/variants/vertical/mod.rs @@ -0,0 +1,35 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + rsx! { + div { + style: "height: 340px; width: 100%; max-width: 720px;", + SplitPane { + direction: SplitPaneDirection::Vertical, + Pane { + default_size: SplitPaneSize::percent(45.0), + min_size: SplitPaneSize::px(110.0), + PaneContent { title: "Timeline", content: "Stack panes vertically for editors, inspectors, and logs." } + } + SplitPaneDivider {} + Pane { + min_size: SplitPaneSize::px(120.0), + PaneContent { title: "Details", content: "The divider uses row-resize behavior and horizontal separator semantics." } + } + } + } + } +} + +#[component] +fn PaneContent(title: &'static str, content: &'static str) -> Element { + rsx! { + div { + style: "height: 100%; box-sizing: border-box; padding: 1rem;", + h3 { style: "margin: 0 0 0.5rem; font-size: 1rem;", "{title}" } + p { style: "margin: 0; color: var(--primary-color-11);", "{content}" } + } + } +} diff --git a/preview/src/components/table_of_contents/component.json b/preview/src/components/table_of_contents/component.json new file mode 100644 index 00000000..74a031a8 --- /dev/null +++ b/preview/src/components/table_of_contents/component.json @@ -0,0 +1,13 @@ +{ + "name": "table_of_contents", + "description": "A table of contents that highlights the active heading while scrolling.", + "authors": ["Dioxus Labs"], + "exclude": ["variants", "docs.md", "component.json"], + "cargoDependencies": [ + { + "name": "dioxus-primitives", + "git": "https://github.com/DioxusLabs/components" + } + ], + "globalAssets": ["../../../assets/dx-components-theme.css"] +} diff --git a/preview/src/components/table_of_contents/component.rs b/preview/src/components/table_of_contents/component.rs new file mode 100644 index 00000000..cefbb1ce --- /dev/null +++ b/preview/src/components/table_of_contents/component.rs @@ -0,0 +1,19 @@ +use dioxus::prelude::*; +use dioxus_primitives::table_of_contents::{self, TableOfContentsProps}; + +#[css_module("/src/components/table_of_contents/style.css")] +struct Styles; + +#[component] +pub fn TableOfContents(props: TableOfContentsProps) -> Element { + rsx! { + table_of_contents::TableOfContents { + class: Styles::dx_table_of_contents, + scroll_spy_options: props.scroll_spy_options, + initial_data: props.initial_data, + min_depth_to_offset: props.min_depth_to_offset, + depth_offset: props.depth_offset, + attributes: props.attributes, + } + } +} diff --git a/preview/src/components/table_of_contents/docs.md b/preview/src/components/table_of_contents/docs.md new file mode 100644 index 00000000..0b47e963 --- /dev/null +++ b/preview/src/components/table_of_contents/docs.md @@ -0,0 +1,13 @@ +## Component Structure + +Table of contents uses `use_scroll_spy()` to discover headings in the document and highlight the item closest to the configured scroll offset. + +## Usage + +Pass a heading selector through `scroll_spy_options` to generate controls from rendered document headings. `initial_data` is optional and only needed when server-rendered markup must include placeholder links before browser-side heading discovery runs. + +Call the hook state's `reinitialize` callback after dynamically changing the heading list. + +## Styling + +The root element has `data-table-of-contents="true"`. Each control receives `data-depth`, and the active control receives `data-active="true"`. diff --git a/preview/src/components/table_of_contents/mod.rs b/preview/src/components/table_of_contents/mod.rs new file mode 100644 index 00000000..2590c013 --- /dev/null +++ b/preview/src/components/table_of_contents/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/preview/src/components/table_of_contents/style.css b/preview/src/components/table_of_contents/style.css new file mode 100644 index 00000000..202c2e20 --- /dev/null +++ b/preview/src/components/table_of_contents/style.css @@ -0,0 +1,33 @@ +.dx-table-of-contents { + display: flex; + flex-direction: column; + min-width: 14rem; + font-size: 0.875rem; +} + +.dx-table-of-contents a { + border-left: 2px solid var(--primary-color-6); + border-radius: 0 0.625rem 0.625rem 0; + color: var(--secondary-color-5); + font-weight: 500; + padding: 0.5rem 0.75rem 0.5rem calc(0.875rem + var(--depth) * var(--depth-offset)); + text-decoration: none; + transition: + border-color 120ms ease, + color 120ms ease, + background-color 120ms ease, + transform 120ms ease; +} + +.dx-table-of-contents a:hover { + background-color: var(--primary-color-4); + color: var(--secondary-color-1); +} + +.dx-table-of-contents a[data-active="true"] { + border-left-color: var(--focused-border-color); + background-color: color-mix(in srgb, var(--focused-border-color) 18%, transparent); + color: var(--secondary-color-1); + font-weight: 700; + transform: translateX(2px); +} diff --git a/preview/src/components/table_of_contents/variants/main/mod.rs b/preview/src/components/table_of_contents/variants/main/mod.rs new file mode 100644 index 00000000..9753e5d7 --- /dev/null +++ b/preview/src/components/table_of_contents/variants/main/mod.rs @@ -0,0 +1,132 @@ +use super::super::component::*; +use dioxus::prelude::*; +use dioxus_primitives::scroll_spy::{ScrollSpyOptions, ScrollSpyScrollHost}; + +#[component] +pub fn Demo() -> Element { + let scroll_spy_options = ScrollSpyOptions { + selector: "article :is(h2, h3, h4)".to_string(), + scroll_host: ScrollSpyScrollHost::Selector("[data-toc-demo-scroll-region]".to_string()), + offset: 88.0, + ..Default::default() + }; + + rsx! { + div { + "data-toc-demo-scroll-region": "true", + display: "grid", + grid_template_columns: "minmax(0, 1fr) 16rem", + gap: "2rem", + align_items: "start", + max_height: "40rem", + overflow_y: "auto", + padding: "2rem", + border: "1px solid var(--primary-color-6)", + border_radius: "1rem", + background: "var(--primary-color-2)", + color: "var(--secondary-color-1)", + + article { + max_width: "44rem", + display: "flex", + flex_direction: "column", + gap: "2.5rem", + padding_bottom: "12rem", + + section { + display: "flex", + flex_direction: "column", + gap: "1rem", + h2 { id: "overview", "Overview" } + p { "The table of contents tracks headings in this document and updates the active link while the scroll container moves through long-form content." } + p { "This preview keeps the navigation pinned in view so you can verify that the highlighted entry changes as each heading crosses the configured offset." } + } + + section { + display: "flex", + flex_direction: "column", + gap: "1rem", + h2 { id: "installation", "Installation" } + p { "Render the table of contents beside the article and provide initial heading data so server rendering and the hydrated client show the same navigation structure." } + p { "The preview intentionally includes enough copy to force scrolling, making it easier to validate active heading transitions instead of relying on a static layout." } + + div { + display: "flex", + flex_direction: "column", + gap: "1rem", + padding_left: "1.5rem", + border_left: "1px solid var(--primary-color-6)", + h3 { id: "configuration", "Configuration" } + p { "Use the selector to scope which headings participate and choose a scroll host when the document uses an internal panel instead of the browser window." } + p { "Indentation in the rendered list reflects heading depth, so a mix of h2, h3, and h4 entries is useful when checking hierarchy." } + + div { + display: "flex", + flex_direction: "column", + gap: "1rem", + padding_left: "1.25rem", + border_left: "1px solid var(--primary-color-6)", + h4 { id: "offsets", "Offsets" } + p { "Offset tuning decides when a heading becomes active. In this demo the active item flips before the heading reaches the top edge, which keeps the label change readable during slower scrolling." } + } + } + } + + section { + display: "flex", + flex_direction: "column", + gap: "1rem", + h2 { id: "api", "API" } + p { "The primitive exposes initial data, selector configuration, a scroll host, and a reinitialize callback for dynamic documents that add or remove headings after first render." } + p { "The active state is still index-based in the core primitive. This preview only improves the surrounding layout and visual feedback." } + + div { + display: "flex", + flex_direction: "column", + gap: "1rem", + padding_left: "1.5rem", + border_left: "1px solid var(--primary-color-6)", + h3 { id: "reinitialization", "Reinitialization" } + p { "Call reinitialize after heading content changes so the hook can rescan the document and keep the table of contents aligned with the rendered article." } + } + } + + section { + display: "flex", + flex_direction: "column", + gap: "1rem", + h2 { id: "styling", "Styling" } + p { "Inactive items should stay readable but subdued. The active item needs stronger contrast, a clearer accent, and preserved indentation so the current heading stands out immediately." } + p { "Keeping the navigation sticky inside the scroll region lets the preview demonstrate both hierarchy and scroll-spy feedback in one compact example." } + + div { + display: "flex", + flex_direction: "column", + gap: "1rem", + padding_left: "1.5rem", + border_left: "1px solid var(--primary-color-6)", + h3 { id: "accessibility", "Accessibility" } + p { "Consistent heading order and stable ids help keyboard users and assistive technology users move between the article and its generated navigation." } + } + } + + section { + display: "flex", + flex_direction: "column", + gap: "1rem", + h2 { id: "usage-notes", "Usage Notes" } + p { "Scroll through this panel to watch the highlighted entry move from section to section. The demo now includes enough vertical space for the active item to change several times before the end of the document." } + p { "If you swap in your own content, keep a similar amount of spacing and section depth so the preview continues to exercise the component meaningfully." } + } + } + + aside { + position: "sticky", + top: "1.5rem", + TableOfContents { + scroll_spy_options, + } + } + } + } +} diff --git a/preview/src/main.rs b/preview/src/main.rs index c5fb6f40..5a1f782f 100644 --- a/preview/src/main.rs +++ b/preview/src/main.rs @@ -21,7 +21,6 @@ use crate::components::{ }; use core::panic; use dioxus::prelude::{dioxus_router::LinkProps, *}; -use dioxus_code::{advanced::HighlightedSource, Code, CodeTheme, Theme}; use dioxus_i18n::prelude::{use_init_i18n, I18nConfig}; use dioxus_icons::lucide::{ ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronLeft, Copy, ExternalLink, Mail, Menu, @@ -406,7 +405,7 @@ fn Footer() -> Element { #[derive(Clone, PartialEq)] pub struct HighlightedCode { - pub source: HighlightedSource, + pub html: &'static str, } #[component] @@ -415,23 +414,16 @@ fn CodeBlock(source: HighlightedCode) -> Element { div { class: "dx-code-block", tabindex: "0", - PreviewCode { source: source.source } + PreviewCode { html: source.html } } CopyButton { position: "absolute", top: "0.5em", right: "0.5em" } } } #[component] -fn PreviewCode(source: HighlightedSource) -> Element { +fn PreviewCode(html: &'static str) -> Element { rsx! { - div { - class: "dx-preview-code-theme", - tabindex: "0", - Code { - src: source, - theme: CodeTheme::system(Theme::GITHUB_LIGHT, Theme::GITHUB_DARK), - } - } + div { dangerous_inner_html: html } } } @@ -1267,10 +1259,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" @@ -1982,5 +1981,8 @@ fn GotoIcon(mut props: LinkProps) -> Element { } const THEME_CSS: HighlightedCode = HighlightedCode { - source: dioxus_code::code!("/assets/dx-components-theme.css"), + html: include_str!(concat!( + env!("OUT_DIR"), + "/assets/dx-components-theme.css.html" + )), }; 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..1897e8de 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; @@ -40,14 +41,20 @@ pub mod popover; mod portal; pub mod progress; pub mod radio_group; +pub mod schedule; pub mod scroll_area; +pub mod scroll_spy; pub mod select; mod selectable; mod selection; pub mod separator; pub mod slider; +pub mod split_pane; pub mod switch; +pub mod table_of_contents; pub mod tabs; +pub mod textarea; +pub mod time_picker; pub mod toast; pub mod toggle; pub mod toggle_group; 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/schedule/components.rs b/primitives/src/schedule/components.rs new file mode 100644 index 00000000..10de4cb2 --- /dev/null +++ b/primitives/src/schedule/components.rs @@ -0,0 +1,2001 @@ +use dioxus::prelude::*; +use time::{Date, Duration, Month, PrimitiveDateTime, Time}; + +use crate::use_controlled; + +use super::state::{ScheduleCapabilities, ScheduleResizeState, ScheduleSlotSelectionState}; +use super::types::*; +use super::utils::{ + current_time_line_offset, expand_events, external_drop_data, filter_events_for_date, + format_date_label, format_day_of_month_label, format_time, format_time_range, is_current_day, + layout_overlapping_events, month_event_segments_for_week, month_grid_dates, + month_weekday_labels, now, resized_event_times, selection_contains, shift_date, + slot_selection_range, time_slots, timed_event_geometry, today, week_dates, + year_month_transition, +}; + +/// # Schedule +/// +/// The `Schedule` component provides primitive scheduling behavior for day, week, +/// month, and year views. Custom event bodies can be supplied with +/// [`ScheduleProps::render_event_body`], which receives a [`ScheduleEventRenderContext`]. +#[component] +pub fn Schedule(props: ScheduleProps) -> Element { + let date_change = props.on_date_change; + let view_change = props.on_view_change; + let initial_date = props.default_date; + let initial_view = props.default_view; + let (date, raw_set_date) = use_controlled(props.date, initial_date, Callback::new(|_| {})); + let (view, raw_set_view) = use_controlled(props.view, initial_view, Callback::new(|_| {})); + let expanded_events = expand_events(&props.events, props.recurrence_expansion_limit); + let current_date = date(); + let current_view = view(); + let dragging_event = use_signal(|| None::); + let drop_target = use_signal(|| None::); + let slot_selection = use_signal(|| None::); + let slot_selection_suppressed_click = use_signal(|| None::); + let resizing_event = use_signal(|| None::); + let resize_target = use_signal(|| None::); + let capabilities = ScheduleCapabilities::new( + props.mode, + props.with_events_drag_and_drop, + props.with_drag_slot_select, + props.with_event_resize, + ); + + let set_date = move |next: Date| { + let previous = date(); + raw_set_date.call(next); + date_change.call(ScheduleDateChange { + previous, + next, + view: view(), + }); + }; + let set_view = move |next: ScheduleView| { + let previous = view(); + raw_set_view.call(next); + view_change.call(ScheduleViewChange { + previous, + next, + date: date(), + }); + }; + + rsx! { + div { + "data-schedule-root": true, + "data-view": current_view.as_str(), + "data-mode": match props.mode { + ScheduleMode::Default => "default", + ScheduleMode::Static => "static", + }, + "data-layout": match props.layout { + ScheduleLayout::Default => "default", + ScheduleLayout::Responsive => "responsive", + }, + "data-locale": props.locale, + "data-dragging": dragging_event().is_some(), + "data-resizing": resizing_event().is_some(), + style: props.radius.as_ref().map(|radius| format!("--dxc-schedule-radius: {radius};")), + ..props.attributes, + + if let Some(header) = props.header { + {header} + } else if props.with_default_header { + ScheduleHeader { + date: current_date, + view: current_view, + labels: props.labels.clone(), + on_previous: move |_| set_date(shift_date(current_date, current_view, -1)), + on_next: move |_| set_date(shift_date(current_date, current_view, 1)), + on_today: move |_| set_date(today()), + on_view: set_view, + } + } + + div { + "data-schedule-desktop": true, + class: props.class_names.desktop_view.clone(), + ScheduleViewBody { + date: current_date, + view: current_view, + events: expanded_events.clone(), + labels: props.labels.clone(), + day_view: props.day_view, + week_view: props.week_view, + month_view: props.month_view, + year_view: props.year_view, + locale: props.locale.clone(), + class_names: props.class_names.clone(), + mobile: false, + capabilities, + dragging_event, + drop_target, + slot_selection, + slot_selection_suppressed_click, + resizing_event, + resize_target, + can_drag_event: props.can_drag_event, + can_resize_event: props.can_resize_event, + render_event_body: props.render_event_body, + on_date: Callback::new(set_date), + on_view: Callback::new(set_view), + on_time_slot_click: props.on_time_slot_click, + on_all_day_slot_click: props.on_all_day_slot_click, + on_day_click: props.on_day_click, + on_event_create: props.on_event_create, + on_event_click: props.on_event_click, + on_event_drag_start: props.on_event_drag_start, + on_event_drag_end: props.on_event_drag_end, + on_event_drop: props.on_event_drop, + on_external_event_drop: props.on_external_event_drop, + on_slot_drag_end: props.on_slot_drag_end, + on_event_resize: props.on_event_resize, + } + } + if props.layout == ScheduleLayout::Responsive { + div { + "data-schedule-mobile": true, + "data-mobile-month-header": props.mobile_month_view.with_default_header, + class: props.class_names.mobile_view.clone(), + ScheduleViewBody { + date: current_date, + view: if current_view == ScheduleView::Year { ScheduleView::Year } else { ScheduleView::Month }, + events: expanded_events, + labels: props.labels, + day_view: props.day_view, + week_view: props.week_view, + month_view: props.month_view, + year_view: props.year_view, + locale: props.locale.clone(), + class_names: props.class_names.clone(), + mobile: true, + mobile_month_view: props.mobile_month_view, + capabilities, + dragging_event, + drop_target, + slot_selection, + slot_selection_suppressed_click, + resizing_event, + resize_target, + can_drag_event: props.can_drag_event, + can_resize_event: props.can_resize_event, + render_event_body: props.render_event_body, + on_date: Callback::new(set_date), + on_view: Callback::new(set_view), + on_time_slot_click: props.on_time_slot_click, + on_all_day_slot_click: props.on_all_day_slot_click, + on_day_click: props.on_day_click, + on_event_create: props.on_event_create, + on_event_click: props.on_event_click, + on_event_drag_start: props.on_event_drag_start, + on_event_drag_end: props.on_event_drag_end, + on_event_drop: props.on_event_drop, + on_external_event_drop: props.on_external_event_drop, + on_slot_drag_end: props.on_slot_drag_end, + on_event_resize: props.on_event_resize, + } + } + } + } + } +} + +/// Props for [`ScheduleHeader`]. +#[derive(Props, Clone, PartialEq)] +pub struct ScheduleHeaderProps { + /// Date used by the header title and navigation callbacks. + pub date: Date, + /// Active schedule view. + pub view: ScheduleView, + /// Labels used by navigation and view controls. + pub labels: ScheduleLabels, + /// Called when the previous range button is clicked. + pub on_previous: Callback, + /// Called when the next range button is clicked. + pub on_next: Callback, + /// Called when the today button is clicked. + pub on_today: Callback, + /// Called when a view button is selected. + pub on_view: Callback, +} + +/// Default schedule header with navigation and view controls. +#[component] +pub fn ScheduleHeader(props: ScheduleHeaderProps) -> Element { + let title = format!("{:?} {}", props.date.month(), props.date.year()); + rsx! { + header { "data-schedule-header": true, + button { + "type": "button", + "aria-label": props.labels.previous.clone(), + onclick: move |event| props.on_previous.call(event), + {props.labels.previous.clone()} + } + button { + "type": "button", + "aria-label": props.labels.today.clone(), + onclick: move |event| props.on_today.call(event), + {props.labels.today.clone()} + } + button { + "type": "button", + "aria-label": props.labels.next.clone(), + onclick: move |event| props.on_next.call(event), + {props.labels.next.clone()} + } + div { "data-schedule-title": true, "{title}" } + nav { + "aria-label": "Schedule views", + "data-schedule-view-controls": true, + ScheduleViewButton { + target: ScheduleView::Day, + current: props.view, + label: props.labels.day, + on_view: props.on_view, + } + ScheduleViewButton { + target: ScheduleView::Week, + current: props.view, + label: props.labels.week, + on_view: props.on_view, + } + ScheduleViewButton { + target: ScheduleView::Month, + current: props.view, + label: props.labels.month, + on_view: props.on_view, + } + ScheduleViewButton { + target: ScheduleView::Year, + current: props.view, + label: props.labels.year, + on_view: props.on_view, + } + } + } + } +} + +/// Props for [`ScheduleViewButton`]. +#[derive(Props, Clone, PartialEq)] +pub struct ScheduleViewButtonProps { + /// View selected by the button. + pub target: ScheduleView, + /// Currently active view. + pub current: ScheduleView, + /// Visible button label. + pub label: String, + /// Called with [`ScheduleViewButtonProps::target`] when clicked. + pub on_view: Callback, +} + +/// Default schedule view switch button used by [`ScheduleHeader`]. +#[component] +pub fn ScheduleViewButton(props: ScheduleViewButtonProps) -> Element { + rsx! { + button { + "type": "button", + "data-schedule-view-button": props.target.as_str(), + "data-active": props.current == props.target, + onclick: move |_| props.on_view.call(props.target), + {props.label} + } + } +} + +#[derive(Props, Clone, PartialEq)] +struct ScheduleViewBodyProps { + date: Date, + view: ScheduleView, + events: Vec, + labels: ScheduleLabels, + day_view: ScheduleDayViewConfig, + week_view: ScheduleWeekViewConfig, + month_view: ScheduleMonthViewConfig, + year_view: ScheduleYearViewConfig, + locale: String, + class_names: ScheduleClassNames, + #[props(default)] + mobile_month_view: ScheduleMobileMonthViewConfig, + mobile: bool, + capabilities: ScheduleCapabilities, + dragging_event: Signal>, + drop_target: Signal>, + slot_selection: Signal>, + slot_selection_suppressed_click: Signal>, + resizing_event: Signal>, + resize_target: Signal>, + can_drag_event: Callback, + can_resize_event: Callback, + render_event_body: Option>, + on_date: Callback, + on_view: Callback, + on_time_slot_click: Callback, + on_all_day_slot_click: Callback, + on_day_click: Callback, + on_event_create: Callback, + on_event_click: Callback, + on_event_drag_start: Callback, + on_event_drag_end: Callback, + on_event_drop: Callback, + on_external_event_drop: Callback, + on_slot_drag_end: Callback, + on_event_resize: Callback, +} + +#[component] +fn ScheduleViewBody(props: ScheduleViewBodyProps) -> Element { + if props.mobile && props.view != ScheduleView::Year { + return rsx! { + MobileMonthView { ..props } + }; + } + match props.view { + ScheduleView::Day => rsx! { + TimeGridView { + body: props.clone(), + days: vec![props.date], + config: props.day_view.time_grid, + } + }, + ScheduleView::Week => rsx! { + TimeGridView { + body: props.clone(), + days: week_dates(props.date, props.week_view.first_day_of_week), + config: props.week_view.time_grid, + } + }, + ScheduleView::Month => rsx! { + MonthView { ..props } + }, + ScheduleView::Year => rsx! { + YearView { ..props } + }, + } +} + +#[derive(Props, Clone, PartialEq)] +struct TimeGridViewProps { + body: ScheduleViewBodyProps, + days: Vec, + config: ScheduleTimeGridConfig, +} + +#[component] +fn TimeGridView(mut props: TimeGridViewProps) -> Element { + let view = props.body.view; + let slots = time_slots(props.config); + let column_template = format!("repeat({}, minmax(0, 1fr))", props.days.len().max(1)); + let current_date_time = now(); + let current_time_line_offset = current_time_line_offset(current_date_time, props.config); + let current_day_index = props + .days + .iter() + .position(|day| *day == current_date_time.date()); + let current_day_count = props.days.len().max(1) as f32; + let current_time_label = format_time(current_date_time.time()); + let all_day_events: Vec<_> = props + .body + .events + .iter() + .filter(|event| event.all_day) + .cloned() + .collect(); + let timed_multi_day_events: Vec<_> = props + .body + .events + .iter() + .filter(|event| !event.all_day && timed_event_spans_multiple_days(event)) + .cloned() + .collect(); + rsx! { + section { + "data-schedule-view": view.as_str(), + "data-mobile": props.body.mobile, + "data-default-header": props.config.with_default_header, + class: match view { + ScheduleView::Day => props.body.class_names.day_view.clone(), + ScheduleView::Week => props.body.class_names.week_view.clone(), + ScheduleView::Month => props.body.class_names.month_view.clone(), + ScheduleView::Year => props.body.class_names.year_view.clone(), + }, + div { + "data-schedule-day-header-row": true, + style: "grid-template-columns: {column_template};", + for day in props.days.iter().copied() { + button { + "type": "button", + "data-schedule-day-header": day.to_string(), + onclick: move |_| { + props + .body + .on_day_click + .call(ScheduleDayClick { + date: day, + view, + }); + }, + {format_date_label(day)} + } + } + } + div { + "data-schedule-all-day-row": true, + style: "grid-template-columns: {column_template};", + for (day_index, day) in props.days.iter().copied().enumerate() { + { + let target_id = format!("all-day-{day}"); + let all_day_label = + format!("{} {}", props.body.labels.all_day, format_date_label(day)); + rsx! { + div { "data-schedule-all-day-column": day.to_string(), + button { + "type": "button", + "data-schedule-all-day-slot": day.to_string(), + "aria-label": all_day_label, + "data-drop-enabled": props.body.capabilities.events_drag_and_drop, + "data-drop-accepted": props.body.capabilities.events_drag_and_drop + && (props.body.dragging_event)().is_some(), + "data-drop-active": (props.body.drop_target)() == Some(target_id.clone()), + "data-drop-denied": (props.body.dragging_event)().is_some() + && !props.body.capabilities.events_drag_and_drop, + class: props.body.class_names.all_day_slot.clone(), + onclick: move |_| { + props + .body + .on_all_day_slot_click + .call(ScheduleAllDaySlotClick { + date: day, + view, + }); + props + .body + .on_event_create + .call( + all_day_event_create( + day, + view, + ScheduleEventCreateSource::AllDaySlotClick, + ), + ); + }, + onmouseup: move |_| { + let start = PrimitiveDateTime::new(day, Time::MIDNIGHT); + move_dragged_event( + props.body.dragging_event, + start, + day, + ScheduleDropDestination::AllDay, + view, + props.body.on_event_drag_end, + props.body.on_event_drop, + ); + }, + onpointerup: move |_| { + let start = PrimitiveDateTime::new(day, Time::MIDNIGHT); + move_dragged_event( + props.body.dragging_event, + start, + day, + ScheduleDropDestination::AllDay, + view, + props.body.on_event_drag_end, + props.body.on_event_drop, + ); + }, + ondragover: move |event| { + if props.body.capabilities.events_drag_and_drop { + event.prevent_default(); + } + }, + ondragenter: { + let target_id = target_id.clone(); + move |event| { + if props.body.capabilities.events_drag_and_drop { + event.prevent_default(); + props.body.drop_target.set(Some(target_id.clone())); + } + } + }, + ondragleave: { + let target_id = target_id.clone(); + move |_| { + if (props.body.drop_target)() == Some(target_id.clone()) { + props.body.drop_target.set(None); + } + } + }, + ondrop: { + let events = props.body.events.clone(); + let target_id = target_id.clone(); + move |event: Event| { + if props.body.capabilities.events_drag_and_drop { + event.prevent_default(); + if (props.body.drop_target)() == Some(target_id.clone()) { + props.body.drop_target.set(None); + } + let start = PrimitiveDateTime::new(day, Time::MIDNIGHT); + let end = start + Duration::days(1); + if !move_dragged_event_from_drop( + props.body.dragging_event, + &event, + &events, + ScheduleDropContext { + new_start: start, + date: day, + destination: ScheduleDropDestination::AllDay, + view, + slot_minutes: None, + }, + props.body.on_event_drop, + ) { + let external = external_drop_data(&event); + props + .body + .on_external_event_drop + .call(ScheduleExternalDrop { + data: external.as_ref().map(|data| data.data.clone()), + data_format: external.map(|data| data.format), + start, + end, + date: day, + view, + }); + } + } + } + }, + if day_index == 0 { + {props.body.labels.all_day.clone()} + } + } + } + } + } + } + div { + "data-schedule-all-day-events": true, + style: "grid-column: 1 / -1; grid-template-columns: {column_template};", + for segment in month_event_segments_for_week(&all_day_events, &props.days) { + ScheduleEventNode { + event: segment.event, + view, + date: segment.start_date, + capabilities: props.body.capabilities, + dragging_event: props.body.dragging_event, + resizing_event: props.body.resizing_event, + resize_target: props.body.resize_target, + can_drag_event: props.body.can_drag_event, + can_resize_event: props.body.can_resize_event, + render_event_body: props.body.render_event_body, + on_event_click: props.body.on_event_click, + on_event_drag_start: props.body.on_event_drag_start, + on_event_drag_end: props.body.on_event_drag_end, + on_event_resize: props.body.on_event_resize, + class_name: props.body.class_names.event.clone(), + layout_style: format!( + "grid-column: {} / span {};", + segment.start_column + 1, + segment.column_span, + ), + } + } + } + } + div { + "data-schedule-time-grid": true, + style: "grid-template-columns: {column_template}; position: relative;", + if let (Some(offset), Some(day_index)) = (current_time_line_offset, current_day_index) { + { + let day_width = 100.0 / current_day_count; + let day_left = day_index as f32 * day_width; + rsx! { + div { + "data-schedule-current-time-line": true, + "aria-label": "Current time {current_time_label}", + style: format!( + "position: absolute; left: 0; right: 0; top: calc({offset:.4}% - 1px); height: 2px; background: color-mix(in srgb, #ef4444 36%, transparent); pointer-events: none; z-index: 2;", + ), + span { + "data-schedule-current-time-label": true, + style: "position: absolute; left: 0; top: 50%; transform: translateY(-50%); border-radius: 999px; background: #ef4444; color: white; padding: 2px 8px; font-size: 0.75rem; font-weight: 700; line-height: 1.2; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22);", + "{current_time_label}" + } + span { + "data-schedule-current-time-segment": true, + style: format!( + "position: absolute; left: {day_left:.4}%; width: {day_width:.4}%; top: 0; height: 2px; background: #ef4444;", + ), + } + span { + "data-schedule-current-time-marker": true, + style: format!( + "position: absolute; left: {day_left:.4}%; top: 50%; width: 10px; height: 10px; border-radius: 999px; background: #ef4444; transform: translate(-50%, -50%);", + ), + } + } + } + } + } + if let Some(style) = timed_drop_preview_style( + (props.body.drop_target)(), + &props.days, + props.config, + (props.body.dragging_event)().as_ref(), + ) { + div { + "data-schedule-drop-preview": true, + "data-drop-active": true, + style, + } + } + for day in props.days.iter().copied() { + div { "data-schedule-day-column": day.to_string(), + div { "data-schedule-day-slots": true, + for slot in slots.iter().copied() { + { + rsx! { + TimeSlot { + date: day, + slot, + view, + slot_minutes: props.config.slot_minutes, + all_events: props.body.events.clone(), + class_name: props.body.class_names.time_slot.clone(), + capabilities: props.body.capabilities, + dragging_event: props.body.dragging_event, + drop_target: props.body.drop_target, + slot_selection: props.body.slot_selection, + slot_selection_suppressed_click: props.body.slot_selection_suppressed_click, + resizing_event: props.body.resizing_event, + resize_target: props.body.resize_target, + on_time_slot_click: props.body.on_time_slot_click, + on_event_create: props.body.on_event_create, + on_event_drop: props.body.on_event_drop, + on_external_event_drop: props.body.on_external_event_drop, + on_slot_drag_end: props.body.on_slot_drag_end, + on_event_resize: props.body.on_event_resize, + can_drag_event: props.body.can_drag_event, + can_resize_event: props.body.can_resize_event, + render_event_body: props.body.render_event_body, + on_event_click: props.body.on_event_click, + on_event_drag_start: props.body.on_event_drag_start, + on_event_drag_end: props.body.on_event_drag_end, + } + } + } + } + } + div { "data-schedule-timed-events": true, + for (event, geometry) in layout_overlapping_events( + filter_events_for_date(&props.body.events, day) + .into_iter() + .filter(|event| { + !event.all_day && !timed_event_spans_multiple_days(event) + }) + .collect(), + ) + .into_iter() + .filter_map(|event| { + timed_event_geometry(&event.event, day, props.config) + .map(|geometry| (event, geometry)) + }) { + ScheduleEventNode { + event: event.event, + view: props.body.view, + date: day, + slot_minutes: props.config.slot_minutes, + layout_column: event.column, + layout_columns: event.columns, + layout_style: timed_event_style(geometry, event.column, event.columns), + capabilities: props.body.capabilities, + dragging_event: props.body.dragging_event, + resizing_event: props.body.resizing_event, + resize_target: props.body.resize_target, + can_drag_event: props.body.can_drag_event, + can_resize_event: props.body.can_resize_event, + render_event_body: props.body.render_event_body, + on_event_click: props.body.on_event_click, + on_event_drag_start: props.body.on_event_drag_start, + on_event_drag_end: props.body.on_event_drag_end, + on_event_resize: props.body.on_event_resize, + class_name: props.body.class_names.event.clone(), + } + } + } + } + } + div { + "data-schedule-timed-spanning-events": true, + style: "grid-column: 1 / -1; position: absolute; inset: 0; pointer-events: none;", + for (event, geometry) in timed_multi_day_events + .iter() + .filter_map(|event| { + timed_spanning_event_geometry(event, &props.days, props.config) + .map(|geometry| (event.clone(), geometry)) + }) { + ScheduleEventNode { + event, + view: props.body.view, + date: geometry.start_date, + slot_minutes: props.config.slot_minutes, + layout_style: timed_spanning_event_style(geometry), + capabilities: props.body.capabilities, + dragging_event: props.body.dragging_event, + resizing_event: props.body.resizing_event, + resize_target: props.body.resize_target, + can_drag_event: props.body.can_drag_event, + can_resize_event: props.body.can_resize_event, + render_event_body: props.body.render_event_body, + on_event_click: props.body.on_event_click, + on_event_drag_start: props.body.on_event_drag_start, + on_event_drag_end: props.body.on_event_drag_end, + on_event_resize: props.body.on_event_resize, + class_name: props.body.class_names.event.clone(), + } + } + } + } + } + } +} + +#[derive(Props, Clone, PartialEq)] +struct TimeSlotProps { + date: Date, + slot: Time, + view: ScheduleView, + slot_minutes: u8, + all_events: Vec, + class_name: String, + capabilities: ScheduleCapabilities, + dragging_event: Signal>, + drop_target: Signal>, + slot_selection: Signal>, + slot_selection_suppressed_click: Signal>, + resizing_event: Signal>, + resize_target: Signal>, + on_time_slot_click: Callback, + on_event_create: Callback, + on_event_drop: Callback, + on_external_event_drop: Callback, + on_slot_drag_end: Callback, + on_event_resize: Callback, + can_drag_event: Callback, + can_resize_event: Callback, + render_event_body: Option>, + on_event_click: Callback, + on_event_drag_start: Callback, + on_event_drag_end: Callback, +} + +#[component] +fn TimeSlot(props: TimeSlotProps) -> Element { + let start = PrimitiveDateTime::new(props.date, props.slot); + let end = start + Duration::minutes(props.slot_minutes.max(1) as i64); + let slot_label = format_time(props.slot); + let dragging_event = props.dragging_event; + let mut drop_target = props.drop_target; + let mut slot_selection = props.slot_selection; + let mut slot_selection_suppressed_click = props.slot_selection_suppressed_click; + let mut resizing_event = props.resizing_event; + let mut resize_target = props.resize_target; + let accepts_schedule_drop = + props.capabilities.events_drag_and_drop || props.capabilities.event_resize; + let drop_interaction_active = dragging_event().is_some() || resizing_event().is_some(); + let target_id = format!("time-{start}"); + let active_target_id = target_id.clone(); + let drop_active = resizing_event().is_some() && drop_target() == Some(target_id.clone()); + let selection = slot_selection(); + let selection_range = selection.and_then(|selection| { + if selection_contains(selection, start) { + let range = slot_selection_range(selection, props.slot_minutes); + Some((range.start.to_string(), range.end.to_string())) + } else { + None + } + }); + let selected = selection_range.is_some(); + let selected_start = selection_range.as_ref().map(|range| range.0.clone()); + let selected_end = selection_range.as_ref().map(|range| range.1.clone()); + let resize_preview = resizing_event().and_then(|resize| { + resize_target().and_then(|target| { + let resized = + resized_event_times(&resize.event, resize.edge, target, props.slot_minutes); + if resized.new_start == start { + let mut event = resize.event.clone(); + event.start = resized.new_start; + event.end = resized.new_end; + Some(event) + } else { + None + } + }) + }); + let resize_preview_style = resize_preview.as_ref().map(|event| { + let slot_minutes = props.slot_minutes.max(1) as f32; + let duration_minutes = (event.end - event.start).whole_minutes().max(1) as f32; + let slots = (duration_minutes / slot_minutes).max(1.0); + format!("--schedule-resize-preview-slots: {slots:.4};") + }); + rsx! { + div { + role: "button", + tabindex: "0", + "data-schedule-time-slot": start.to_string(), + "data-slot-select-enabled": props.capabilities.drag_slot_select, + "data-drop-enabled": accepts_schedule_drop, + "data-drop-accepted": accepts_schedule_drop && drop_interaction_active, + "data-drop-active": drop_active, + "data-drop-denied": drop_interaction_active && !accepts_schedule_drop, + "data-selected-range": selected, + "data-selected-range-start": selected_start, + "data-selected-range-end": selected_end, + class: props.class_name, + onclick: move |_| { + if let Some(suppressed_start) = slot_selection_suppressed_click() { + slot_selection_suppressed_click.set(None); + if suppressed_start == start { + return; + } + } + props + .on_time_slot_click + .call(ScheduleTimeSlotClick { + start, + end, + date: props.date, + view: props.view, + }); + props + .on_event_create + .call(ScheduleEventCreate { + start, + end, + date: props.date, + all_day: false, + view: props.view, + source: ScheduleEventCreateSource::TimeSlotClick, + }); + }, + onmousedown: move |_| { + if props.capabilities.drag_slot_select { + slot_selection + .set( + Some(ScheduleSlotSelectionState { + anchor: start, + current: start, + }), + ); + } + }, + onmouseenter: move |_| { + if resizing_event().is_some() { + resize_target.set(Some(start)); + return; + } + if props.capabilities.drag_slot_select { + if let Some(mut selection) = slot_selection() { + selection.current = start; + slot_selection.set(Some(selection)); + } + } + }, + onmouseup: move |_| { + if props.capabilities.event_resize && resizing_event().is_some() { + let resize = resizing_event.take().unwrap(); + let resized = + resized_event_times(&resize.event, resize.edge, start, props.slot_minutes); + props + .on_event_resize + .call(ScheduleEventResize { + event_id: resize.event.id.clone(), + event: resize.event, + new_start: resized.new_start, + new_end: resized.new_end, + edge: resize.edge, + view: props.view, + }); + resize_target.set(None); + slot_selection_suppressed_click.set(Some(start)); + return; + } + if props.capabilities.drag_slot_select { + if let Some(mut selection) = slot_selection.take() { + selection.current = start; + let range = slot_selection_range(selection, props.slot_minutes); + props + .on_slot_drag_end + .call(ScheduleSlotRangeSelection { + start: range.start, + end: range.end, + view: props.view, + }); + if selection.anchor != selection.current { + props + .on_event_create + .call(ScheduleEventCreate { + start: range.start, + end: range.end, + date: range.start.date(), + all_day: false, + view: props.view, + source: ScheduleEventCreateSource::TimeSlotDrag, + }); + slot_selection_suppressed_click.set(Some(start)); + } + } + } + }, + onpointerup: move |_| { + if props.capabilities.event_resize && resizing_event().is_some() { + let resize = resizing_event.take().unwrap(); + let resized = + resized_event_times(&resize.event, resize.edge, start, props.slot_minutes); + props + .on_event_resize + .call(ScheduleEventResize { + event_id: resize.event.id.clone(), + event: resize.event, + new_start: resized.new_start, + new_end: resized.new_end, + edge: resize.edge, + view: props.view, + }); + resize_target.set(None); + slot_selection_suppressed_click.set(Some(start)); + } + }, + ondragover: move |event| { + if accepts_schedule_drop { + event.prevent_default(); + } + if resizing_event().is_some() { + resize_target.set(Some(start)); + } + }, + ondragenter: { + let target_id = target_id.clone(); + move |event| { + if accepts_schedule_drop { + event.prevent_default(); + } + drop_target.set(Some(target_id.clone())); + if resizing_event().is_some() { + resize_target.set(Some(start)); + } + } + }, + ondragleave: { + let target_id = target_id.clone(); + move |_| { + if drop_target() == Some(target_id.clone()) { + drop_target.set(None); + } + if resize_target() == Some(start) { + resize_target.set(None); + } + } + }, + ondrop: move |event: Event| { + event.prevent_default(); + if drop_target() == Some(active_target_id.clone()) { + drop_target.set(None); + } + if props.capabilities.event_resize && resizing_event().is_some() { + let resize = resizing_event.take().unwrap(); + let resized = + resized_event_times(&resize.event, resize.edge, start, props.slot_minutes); + props + .on_event_resize + .call(ScheduleEventResize { + event_id: resize.event.id.clone(), + event: resize.event, + new_start: resized.new_start, + new_end: resized.new_end, + edge: resize.edge, + view: props.view, + }); + resize_target.set(None); + slot_selection_suppressed_click.set(Some(start)); + } else if props.capabilities.events_drag_and_drop + && move_dragged_event_from_drop( + dragging_event, + &event, + &props.all_events, + ScheduleDropContext { + new_start: start, + date: props.date, + destination: ScheduleDropDestination::Timed, + view: props.view, + slot_minutes: Some(props.slot_minutes), + }, + props.on_event_drop, + ) + {} else if props.capabilities.events_drag_and_drop { + let external = external_drop_data(&event); + props + .on_external_event_drop + .call(ScheduleExternalDrop { + data: external.as_ref().map(|data| data.data.clone()), + data_format: external.map(|data| data.format), + start, + end, + date: props.date, + view: props.view, + }); + } + }, + span { "data-schedule-time-slot-label": true, "{slot_label}" } + if let Some(preview) = resize_preview { + article { + "data-schedule-event": preview.id.clone(), + "data-schedule-resize-preview": true, + "data-color": preview.color.clone().unwrap_or_default(), + "data-all-day": false, + "data-draggable": false, + "data-resizable": false, + "style": resize_preview_style, + strong { "{preview.title}" } + span { "data-schedule-event-time": true, + " {format_time_range(preview.start, preview.end)}" + } + } + } + } + } +} + +#[component] +fn MonthView(mut props: ScheduleViewBodyProps) -> Element { + let days = month_grid_dates(props.date, props.month_view.first_day_of_week); + let weekday_labels = month_weekday_labels(props.month_view.first_day_of_week, &props.locale); + rsx! { + section { + "data-schedule-view": "month", + "data-mobile": props.mobile, + "data-default-header": props.month_view.with_default_header, + class: props.class_names.month_view.clone(), + div { "data-schedule-month-weekdays": true, + for label in weekday_labels { + span { "data-schedule-month-weekday": true, "{label}" } + } + } + for week in days.chunks(7) { + div { "data-schedule-month-week": true, + div { "data-schedule-month-week-days": true, + for day in week.iter().copied() { + div { + "data-schedule-month-day": day.to_string(), + "data-outside-month": day.month() != props.date.month(), + "data-current-day": is_current_day(day), + "data-drop-enabled": props.capabilities.events_drag_and_drop, + "data-drop-accepted": props.capabilities.events_drag_and_drop && (props.dragging_event)().is_some(), + "data-drop-active": (props.drop_target)() == Some(format!("month-{day}")), + "data-drop-denied": (props.dragging_event)().is_some() && !props.capabilities.events_drag_and_drop, + class: props.class_names.month_day.clone(), + onmouseup: move |_| { + if props.capabilities.events_drag_and_drop { + let start = PrimitiveDateTime::new(day, Time::MIDNIGHT); + move_dragged_event( + props.dragging_event, + start, + day, + ScheduleDropDestination::Timed, + props.view, + props.on_event_drag_end, + props.on_event_drop, + ); + } + }, + onpointerup: move |_| { + if props.capabilities.events_drag_and_drop { + let start = PrimitiveDateTime::new(day, Time::MIDNIGHT); + move_dragged_event( + props.dragging_event, + start, + day, + ScheduleDropDestination::Timed, + props.view, + props.on_event_drag_end, + props.on_event_drop, + ); + } + }, + ondragover: move |event| { + if props.capabilities.events_drag_and_drop { + event.prevent_default(); + } + }, + ondragenter: move |event| { + if props.capabilities.events_drag_and_drop { + event.prevent_default(); + props.drop_target.set(Some(format!("month-{day}"))); + } + }, + ondragleave: move |_| { + if (props.drop_target)() == Some(format!("month-{day}")) { + props.drop_target.set(None); + } + }, + ondrop: { + let events = props.events.clone(); + move |event: Event| { + if props.capabilities.events_drag_and_drop { + event.prevent_default(); + if (props.drop_target)() == Some(format!("month-{day}")) { + props.drop_target.set(None); + } + let start = PrimitiveDateTime::new(day, Time::MIDNIGHT); + let end = start + Duration::days(1); + if !move_dragged_event_from_drop( + props.dragging_event, + &event, + &events, + ScheduleDropContext { + new_start: start, + date: day, + destination: ScheduleDropDestination::Timed, + view: props.view, + slot_minutes: None, + }, + props.on_event_drop, + ) { + let external = external_drop_data(&event); + props + .on_external_event_drop + .call(ScheduleExternalDrop { + data: external.as_ref().map(|data| data.data.clone()), + data_format: external.map(|data| data.format), + start, + end, + date: day, + view: props.view, + }); + } + } + } + }, + button { + "type": "button", + "data-schedule-month-day-button": day.to_string(), + onclick: move |_| { + props + .on_day_click + .call(ScheduleDayClick { + date: day, + view: props.view, + }); + }, + {format_day_of_month_label(day)} + } + } + } + } + div { "data-schedule-month-week-events": true, + for segment in month_event_segments_for_week(&props.events, week) { + ScheduleEventNode { + event: segment.event, + view: props.view, + date: segment.start_date, + capabilities: props.capabilities, + dragging_event: props.dragging_event, + resizing_event: props.resizing_event, + resize_target: props.resize_target, + can_drag_event: props.can_drag_event, + can_resize_event: props.can_resize_event, + render_event_body: props.render_event_body, + on_event_click: props.on_event_click, + on_event_drag_start: props.on_event_drag_start, + on_event_drag_end: props.on_event_drag_end, + on_event_resize: props.on_event_resize, + class_name: props.class_names.event.clone(), + layout_style: format!( + "grid-column: {} / span {};", + segment.start_column + 1, + segment.column_span, + ), + } + } + } + } + } + } + } +} + +#[component] +fn MobileMonthView(mut props: ScheduleViewBodyProps) -> Element { + let days = month_grid_dates(props.date, props.month_view.first_day_of_week); + let mobile_events_by_day: Vec<_> = days + .iter() + .copied() + .map(|day| (day, filter_events_for_date(&props.events, day))) + .collect(); + rsx! { + section { + "data-schedule-view": "mobile-month", + "data-mobile": true, + "data-mobile-month-view": true, + "data-default-header": props.mobile_month_view.with_default_header, + class: props.class_names.mobile_month_view.clone(), + for (day, events) in mobile_events_by_day { + div { + "data-schedule-mobile-month-day": day.to_string(), + "data-current-day": is_current_day(day), + "data-outside-month": day.month() != props.date.month(), + "data-drop-enabled": props.capabilities.events_drag_and_drop, + "data-drop-accepted": props.capabilities.events_drag_and_drop && (props.dragging_event)().is_some(), + "data-drop-active": (props.drop_target)() == Some(format!("mobile-month-{day}")), + "data-drop-denied": (props.dragging_event)().is_some() && !props.capabilities.events_drag_and_drop, + class: props.class_names.month_day.clone(), + onmouseup: move |_| { + if props.capabilities.events_drag_and_drop { + let start = PrimitiveDateTime::new(day, Time::MIDNIGHT); + move_dragged_event( + props.dragging_event, + start, + day, + ScheduleDropDestination::Timed, + props.view, + props.on_event_drag_end, + props.on_event_drop, + ); + } + }, + onpointerup: move |_| { + if props.capabilities.events_drag_and_drop { + let start = PrimitiveDateTime::new(day, Time::MIDNIGHT); + move_dragged_event( + props.dragging_event, + start, + day, + ScheduleDropDestination::Timed, + props.view, + props.on_event_drag_end, + props.on_event_drop, + ); + } + }, + ondragover: move |event| { + if props.capabilities.events_drag_and_drop { + event.prevent_default(); + } + }, + ondragenter: move |event| { + if props.capabilities.events_drag_and_drop { + event.prevent_default(); + props.drop_target.set(Some(format!("mobile-month-{day}"))); + } + }, + ondragleave: move |_| { + if (props.drop_target)() == Some(format!("mobile-month-{day}")) { + props.drop_target.set(None); + } + }, + ondrop: { + let events = props.events.clone(); + move |event: Event| { + if props.capabilities.events_drag_and_drop { + event.prevent_default(); + if (props.drop_target)() == Some(format!("mobile-month-{day}")) { + props.drop_target.set(None); + } + let start = PrimitiveDateTime::new(day, Time::MIDNIGHT); + let end = start + Duration::days(1); + if !move_dragged_event_from_drop( + props.dragging_event, + &event, + &events, + ScheduleDropContext { + new_start: start, + date: day, + destination: ScheduleDropDestination::Timed, + view: props.view, + slot_minutes: None, + }, + props.on_event_drop, + ) { + let external = external_drop_data(&event); + props + .on_external_event_drop + .call(ScheduleExternalDrop { + data: external.as_ref().map(|data| data.data.clone()), + data_format: external.map(|data| data.format), + start, + end, + date: day, + view: props.view, + }); + } + } + } + }, + button { + "type": "button", + "data-schedule-mobile-month-day-button": day.to_string(), + onclick: move |_| { + props + .on_day_click + .call(ScheduleDayClick { + date: day, + view: props.view, + }); + }, + {format_day_of_month_label(day)} + } + for event in events { + ScheduleEventNode { + event, + view: props.view, + date: day, + capabilities: props.capabilities, + dragging_event: props.dragging_event, + resizing_event: props.resizing_event, + resize_target: props.resize_target, + can_drag_event: props.can_drag_event, + can_resize_event: props.can_resize_event, + render_event_body: props.render_event_body, + on_event_click: props.on_event_click, + on_event_drag_start: props.on_event_drag_start, + on_event_drag_end: props.on_event_drag_end, + on_event_resize: props.on_event_resize, + class_name: props.class_names.event.clone(), + } + } + } + } + } + } +} + +#[component] +fn YearView(mut props: ScheduleViewBodyProps) -> Element { + let weekday_labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + rsx! { + section { + "data-schedule-view": "year", + "data-default-header": props.year_view.with_default_header, + "data-mobile": props.mobile, + class: props.class_names.year_view.clone(), + for month_number in 1u8..=12 { + { + let month = Month::try_from(month_number).unwrap(); + let month_label = format!("{month:?}"); + let month_date = + Date::from_calendar_date(props.date.year(), month, 1).unwrap(); + let days = month_grid_dates(month_date, props.month_view.first_day_of_week); + rsx! { + button { + "type": "button", + "data-schedule-year-month": month_number, + "data-current-month": props.date.month() == month, + "data-drop-enabled": props.capabilities.events_drag_and_drop, + "data-drop-accepted": props.capabilities.events_drag_and_drop && (props.dragging_event)().is_some(), + "data-drop-active": (props.drop_target)() == Some(format!("year-{month_number}")), + "data-drop-denied": (props.dragging_event)().is_some() && !props.capabilities.events_drag_and_drop, + onmouseup: move |_| { + if props.capabilities.events_drag_and_drop { + let date = year_month_transition(props.date, month_number); + let start = PrimitiveDateTime::new(date, Time::MIDNIGHT); + move_dragged_event( + props.dragging_event, + start, + date, + ScheduleDropDestination::Timed, + props.view, + props.on_event_drag_end, + props.on_event_drop, + ); + } + }, + onpointerup: move |_| { + if props.capabilities.events_drag_and_drop { + let date = year_month_transition(props.date, month_number); + let start = PrimitiveDateTime::new(date, Time::MIDNIGHT); + move_dragged_event( + props.dragging_event, + start, + date, + ScheduleDropDestination::Timed, + props.view, + props.on_event_drag_end, + props.on_event_drop, + ); + } + }, + ondragover: move |event| { + if props.capabilities.events_drag_and_drop { + event.prevent_default(); + } + }, + ondragenter: move |event| { + if props.capabilities.events_drag_and_drop { + event.prevent_default(); + props.drop_target.set(Some(format!("year-{month_number}"))); + } + }, + ondragleave: move |_| { + if (props.drop_target)() == Some(format!("year-{month_number}")) { + props.drop_target.set(None); + } + }, + ondrop: { + let events = props.events.clone(); + move |event: Event| { + if props.capabilities.events_drag_and_drop { + event.prevent_default(); + if (props.drop_target)() == Some(format!("year-{month_number}")) { + props.drop_target.set(None); + } + let date = year_month_transition(props.date, month_number); + let start = PrimitiveDateTime::new(date, Time::MIDNIGHT); + let end = start + Duration::days(1); + if !move_dragged_event_from_drop( + props.dragging_event, + &event, + &events, + ScheduleDropContext { + new_start: start, + date, + destination: ScheduleDropDestination::Timed, + view: props.view, + slot_minutes: None, + }, + props.on_event_drop, + ) { + let external = external_drop_data(&event); + props + .on_external_event_drop + .call(ScheduleExternalDrop { + data: external.as_ref().map(|data| data.data.clone()), + data_format: external.map(|data| data.format), + start, + end, + date, + view: props.view, + }); + } + } + } + }, + onclick: move |_| { + let next = year_month_transition(props.date, month_number); + props.on_date.call(next); + props.on_view.call(ScheduleView::Month); + }, + div { "data-schedule-year-month-header": true, + span { "data-schedule-year-month-label": true, "{month_label}" } + span { "data-schedule-year-month-meta": true, "{props.date.year()}" } + } + div { "data-schedule-year-weekdays": true, + for offset in 0..7 { + { + let index = (props.month_view.first_day_of_week.number_days_from_sunday() + as usize + offset) % 7; + rsx! { + span { "data-schedule-year-weekday": true, "{weekday_labels[index]}" } + } + } + } + } + div { "data-schedule-year-days": true, + for day in days { + { + let day_events = filter_events_for_date(&props.events, day); + let visible_dots = day_events.iter().take(3).cloned().collect::>(); + let overflow_count = day_events.len().saturating_sub(visible_dots.len()); + let is_selected_day = day == props.date; + rsx! { + div { + "data-schedule-year-day": true, + "data-outside-month": day.month() != month, + "data-current-day": is_current_day(day), + "data-selected-day": is_selected_day, + "data-weekend": matches!(day.weekday(), time::Weekday::Saturday | time::Weekday::Sunday), + span { "data-schedule-year-day-number": true, "{day.day()}" } + div { "data-schedule-year-day-dots": true, + for event in visible_dots { + span { + "data-schedule-event-dot": true, + "data-color": event.color.clone().unwrap_or_default(), + title: event.title, + } + } + if overflow_count > 0 { + span { "data-schedule-year-day-overflow": true, "+{overflow_count}" } + } + } + } + } + } + } + } + } + } + } + } + } + } +} + +#[derive(Props, Clone, PartialEq)] +struct ScheduleEventNodeProps { + event: ScheduleEvent, + view: ScheduleView, + date: Date, + capabilities: ScheduleCapabilities, + dragging_event: Signal>, + resizing_event: Signal>, + resize_target: Signal>, + can_drag_event: Callback, + can_resize_event: Callback, + render_event_body: Option>, + on_event_click: Callback, + on_event_drag_start: Callback, + on_event_drag_end: Callback, + on_event_resize: Callback, + #[props(default = 60)] + slot_minutes: u8, + #[props(default)] + class_name: String, + #[props(default)] + layout_column: usize, + #[props(default = 1)] + layout_columns: usize, + #[props(default)] + layout_style: String, +} + +#[component] +fn ScheduleEventNode(props: ScheduleEventNodeProps) -> Element { + let draggable = + props.capabilities.events_drag_and_drop && props.can_drag_event.call(props.event.clone()); + let resizable = !props.event.all_day + && props.capabilities.event_resize + && props.can_resize_event.call(props.event.clone()); + let event_id = props.event.id.clone(); + let event_title = props.event.title.clone(); + let event_color = props.event.color.clone().unwrap_or_default(); + let event_label = props + .event + .description + .clone() + .unwrap_or_else(|| props.event.title.clone()); + let event_time = if props.event.all_day { + "All day".to_string() + } else { + format_time_range(props.event.start, props.event.end) + }; + let click_event = props.event.clone(); + let drag_start_event = props.event.clone(); + let drag_end_event = props.event.clone(); + let mut drag_start_signal = props.dragging_event; + let drag_end_signal = props.dragging_event; + let mut resize_start_signal = props.resizing_event; + let mut resize_end_signal = props.resizing_event; + let mut resize_start_target = props.resize_target; + let mut resize_end_target = props.resize_target; + let mut resize_start_drag_signal = props.dragging_event; + let mut resize_end_drag_signal = props.dragging_event; + let resize_slot_duration = Duration::minutes(props.slot_minutes.max(1) as i64); + let resize_start_pointer_event = props.event.clone(); + let resize_end_pointer_event = props.event.clone(); + let context = ScheduleEventRenderContext { + event: props.event.clone(), + view: props.view, + date: props.date, + draggable, + resizable, + }; + let is_resize_source = (props.resizing_event)() + .as_ref() + .is_some_and(|resize| resize.event.id == event_id.as_str()); + let event_draggable = draggable && !is_resize_source; + rsx! { + article { + "data-schedule-event": event_id.clone(), + "data-color": event_color, + "data-all-day": props.event.all_day, + "data-draggable": event_draggable, + "data-resizable": resizable, + "data-drag-disabled": !draggable, + "data-resize-disabled": !resizable, + "data-resize-source": is_resize_source, + "data-disabled": !draggable && !resizable, + "data-layout-column": props.layout_column, + "data-layout-columns": props.layout_columns, + "title": event_label, + class: props.class_name, + style: (!props.layout_style.is_empty()).then_some(props.layout_style.clone()), + draggable: event_draggable, + onclick: move |event| { + event.stop_propagation(); + props + .on_event_click + .call(ScheduleEventClick { + event: click_event.clone(), + view: props.view, + }); + }, + onmousedown: move |event| { + event.stop_propagation(); + }, + onpointerdown: move |event: Event| { + event.stop_propagation(); + }, + ondragstart: move |event: Event| { + if (props.resizing_event)().is_some() { + event.prevent_default(); + return; + } + if event_draggable { + event.data_transfer().set_effect_allowed("move"); + event.data_transfer().set_drop_effect("move"); + let _ = event.data_transfer().set_data("text/plain", &drag_start_event.id); + let _ = event + .data_transfer() + .set_data("application/x-dioxus-schedule-event", &drag_start_event.id); + drag_start_signal.set(Some(drag_start_event.clone())); + props + .on_event_drag_start + .call(ScheduleEventDrag { + event: drag_start_event.clone(), + view: props.view, + }); + } + }, + ondragend: move |_| { + if event_draggable { + clear_dragging_event(drag_end_signal); + props + .on_event_drag_end + .call(ScheduleEventDrag { + event: drag_end_event.clone(), + view: props.view, + }); + } + }, + if let Some(render) = props.render_event_body { + {render.call(context)} + } else { + strong { "{event_title}" } + span { "data-schedule-event-time": true, " {event_time}" } + } + if resizable { + button { + "type": "button", + "data-schedule-resize-handle": "start", + draggable: false, + onmousedown: move |event| { + event.stop_propagation(); + event.prevent_default(); + }, + onpointerdown: move |event| { + event.stop_propagation(); + event.prevent_default(); + resize_start_drag_signal.set(None); + resize_start_target.set(Some(resize_start_pointer_event.start)); + resize_start_signal + .set( + Some(ScheduleResizeState { + event: resize_start_pointer_event.clone(), + edge: ScheduleResizeEdge::Start, + }), + ); + }, + onclick: move |event| { + event.stop_propagation(); + }, + ondragstart: move |event: Event| { + event.stop_propagation(); + event.prevent_default(); + }, + ondragend: move |_| { + resize_start_drag_signal.set(None); + resize_start_signal.set(None); + resize_start_target.set(None); + }, + "Resize start" + } + button { + "type": "button", + "data-schedule-resize-handle": "end", + draggable: false, + onmousedown: move |event| { + event.stop_propagation(); + event.prevent_default(); + }, + onpointerdown: move |event| { + event.stop_propagation(); + event.prevent_default(); + resize_end_drag_signal.set(None); + resize_end_target.set(Some( + resize_end_pointer_event.end - resize_slot_duration + )); + resize_end_signal + .set( + Some(ScheduleResizeState { + event: resize_end_pointer_event.clone(), + edge: ScheduleResizeEdge::End, + }), + ); + }, + onclick: move |event| { + event.stop_propagation(); + }, + ondragstart: move |event: Event| { + event.stop_propagation(); + event.prevent_default(); + }, + ondragend: move |_| { + resize_end_drag_signal.set(None); + resize_end_signal.set(None); + resize_end_target.set(None); + }, + "Resize end" + } + } + } + } +} + +pub(crate) fn all_day_event_create( + date: Date, + view: ScheduleView, + source: ScheduleEventCreateSource, +) -> ScheduleEventCreate { + let start = PrimitiveDateTime::new(date, Time::MIDNIGHT); + ScheduleEventCreate { + start, + end: start + Duration::days(1), + date, + all_day: true, + view, + source, + } +} + +pub(crate) fn clear_dragging_event(mut dragging_event: Signal>) { + dragging_event.set(None); +} + +fn move_dragged_event( + mut dragging_event: Signal>, + new_start: PrimitiveDateTime, + date: Date, + destination: ScheduleDropDestination, + view: ScheduleView, + on_event_drag_end: Callback, + on_event_drop: Callback, +) -> bool { + let Some(event) = dragging_event.take() else { + return false; + }; + let drag = ScheduleEventDrag { + event: event.clone(), + view, + }; + on_event_drop.call(build_event_drop( + event, + new_start, + date, + destination, + view, + None, + )); + on_event_drag_end.call(drag); + true +} + +fn move_dragged_event_from_drop( + mut dragging_event: Signal>, + drop_event: &Event, + events: &[ScheduleEvent], + context: ScheduleDropContext, + on_event_drop: Callback, +) -> bool { + let dragged_event = dragging_event + .take() + .or_else(|| event_from_drop_data(drop_event, events)); + let Some(event) = dragged_event else { + return false; + }; + on_event_drop.call(build_event_drop( + event, + context.new_start, + context.date, + context.destination, + context.view, + context.slot_minutes, + )); + true +} + +#[derive(Clone, Copy)] +struct ScheduleDropContext { + new_start: PrimitiveDateTime, + date: Date, + destination: ScheduleDropDestination, + view: ScheduleView, + slot_minutes: Option, +} + +pub(crate) fn build_event_drop( + event: ScheduleEvent, + new_start: PrimitiveDateTime, + date: Date, + destination: ScheduleDropDestination, + view: ScheduleView, + slot_minutes: Option, +) -> ScheduleEventDrop { + let duration = match (destination, event.all_day, slot_minutes) { + (ScheduleDropDestination::Timed, true, Some(slot_minutes)) => { + Duration::minutes(slot_minutes.max(1) as i64) + } + _ => event.end - event.start, + }; + ScheduleEventDrop { + event_id: event.id.clone(), + event, + destination, + new_start, + new_end: new_start + duration, + date, + view, + } +} + +fn timed_event_style( + geometry: super::utils::TimedEventGeometry, + column: usize, + columns: usize, +) -> String { + let columns = columns.max(1) as f32; + let width = 100.0 / columns; + let left = column as f32 * width; + format!( + "top: calc(var(--schedule-time-slot-size) * {:.4} + 2px); height: calc(var(--schedule-time-slot-size) * {:.4} - 2px); left: calc({:.4}% + 4px); width: calc({:.4}% - 8px);", + geometry.top_slots, geometry.height_slots, left, width, + ) +} + +pub(crate) fn timed_event_spans_multiple_days(event: &ScheduleEvent) -> bool { + let inclusive_end = event.end - Duration::nanoseconds(1); + event.end > event.start && event.start.date() != inclusive_end.date() +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct TimedSpanningEventGeometry { + pub(crate) start_column: usize, + pub(crate) column_span: usize, + pub(crate) day_count: usize, + pub(crate) start_date: Date, + pub(crate) top_slots: f32, + pub(crate) height_slots: f32, +} + +pub(crate) fn timed_spanning_event_geometry( + event: &ScheduleEvent, + days: &[Date], + config: ScheduleTimeGridConfig, +) -> Option { + if event.all_day || !timed_event_spans_multiple_days(event) { + return None; + } + + let mut start_column = None::; + let mut end_column = None::; + let mut top_slots = None::; + let mut bottom_slots = None::; + + for (index, day) in days.iter().copied().enumerate() { + let Some(geometry) = timed_event_geometry(event, day, config) else { + continue; + }; + + start_column.get_or_insert(index); + end_column = Some(index); + top_slots = Some(top_slots.map_or(geometry.top_slots, |top| top.min(geometry.top_slots))); + bottom_slots = Some( + bottom_slots.map_or(geometry.top_slots + geometry.height_slots, |bottom| { + bottom.max(geometry.top_slots + geometry.height_slots) + }), + ); + } + + let start_column = start_column?; + let end_column = end_column?; + let top_slots = top_slots?; + let bottom_slots = bottom_slots?; + if bottom_slots <= top_slots { + return None; + } + + Some(TimedSpanningEventGeometry { + start_column, + column_span: end_column.saturating_sub(start_column) + 1, + day_count: days.len().max(1), + start_date: days[start_column], + top_slots, + height_slots: bottom_slots - top_slots, + }) +} + +fn timed_spanning_event_style(geometry: TimedSpanningEventGeometry) -> String { + let day_count = geometry.day_count.max(1) as f32; + let day_width = 100.0 / day_count; + let left = geometry.start_column as f32 * day_width; + let width = geometry.column_span as f32 * day_width; + format!( + "top: calc(var(--schedule-time-slot-size) * {:.4} + 2px); height: calc(var(--schedule-time-slot-size) * {:.4} - 2px); left: calc({:.4}% + 4px); width: calc({:.4}% - 8px); pointer-events: auto;", + geometry.top_slots, geometry.height_slots, left, width, + ) +} + +pub(crate) fn timed_drop_preview_style( + drop_target: Option, + days: &[Date], + config: ScheduleTimeGridConfig, + dragging_event: Option<&ScheduleEvent>, +) -> Option { + let dragging_event = dragging_event?; + let target = drop_target?; + let target_start = time_slots(config) + .into_iter() + .flat_map(|slot| { + days.iter() + .copied() + .map(move |day| PrimitiveDateTime::new(day, slot)) + }) + .find(|start| target == format!("time-{start}"))?; + let day_index = days.iter().position(|day| *day == target_start.date())?; + let duration = if dragging_event.all_day { + Duration::minutes(config.slot_minutes.max(1) as i64) + } else { + (dragging_event.end - dragging_event.start) + .max(Duration::minutes(config.slot_minutes.max(1) as i64)) + }; + let mut preview = dragging_event.clone(); + preview.start = target_start; + preview.end = target_start + duration; + preview.all_day = false; + if let Some(geometry) = timed_spanning_event_geometry(&preview, days, config) { + return Some(timed_spanning_event_style(geometry)); + } + let geometry = timed_event_geometry(&preview, target_start.date(), config)?; + let day_count = days.len().max(1) as f32; + let width = 100.0 / day_count; + let left = day_index as f32 * width; + Some(format!( + "top: calc(var(--schedule-time-slot-size) * {:.4} + 2px); height: calc(var(--schedule-time-slot-size) * {:.4} - 2px); left: calc({:.4}% + 4px); width: calc({:.4}% - 8px);", + geometry.top_slots, geometry.height_slots, left, width, + )) +} + +pub(crate) fn time_slot_drop_active( + drop_target: Option, + slot_start: PrimitiveDateTime, + slot_minutes: u8, + dragging_event: Option<&ScheduleEvent>, +) -> bool { + let Some(drop_target) = drop_target else { + return false; + }; + let _ = (slot_minutes, dragging_event); + drop_target == format!("time-{slot_start}") +} + +fn event_from_drop_data( + drop_event: &Event, + events: &[ScheduleEvent], +) -> Option { + let event_id = drop_event + .data_transfer() + .get_data("application/x-dioxus-schedule-event") + .or_else(|| drop_event.data_transfer().get_data("text/plain"))?; + + events.iter().find(|event| event.id == event_id).cloned() +} diff --git a/primitives/src/schedule/mod.rs b/primitives/src/schedule/mod.rs new file mode 100644 index 00000000..920a9bb3 --- /dev/null +++ b/primitives/src/schedule/mod.rs @@ -0,0 +1,24 @@ +//! Defines the [`Schedule`] component and supporting scheduling data types. + +mod components; +mod state; +#[cfg(test)] +mod tests; +mod types; +mod utils; + +pub use components::{ + Schedule, ScheduleHeader, ScheduleHeaderProps, ScheduleViewButton, ScheduleViewButtonProps, +}; +pub use types::{ + ScheduleAllDaySlotClick, ScheduleClassNames, ScheduleDateChange, ScheduleDayClick, + ScheduleDayViewConfig, ScheduleDropDestination, ScheduleEvent, ScheduleEventClick, + ScheduleEventCreate, ScheduleEventCreateSource, ScheduleEventDrag, ScheduleEventDrop, + ScheduleEventRenderContext, ScheduleEventResize, ScheduleExternalDrop, ScheduleLabels, + ScheduleLayout, ScheduleMobileMonthViewConfig, ScheduleMode, ScheduleMonthViewConfig, + ScheduleProps, ScheduleRecurrence, ScheduleRecurrenceExpansionLimit, + ScheduleRecurrenceFrequency, ScheduleResizeEdge, ScheduleSlotRangeSelection, + ScheduleTimeGridConfig, ScheduleTimeSlotClick, ScheduleView, ScheduleViewChange, + ScheduleWeekViewConfig, ScheduleYearViewConfig, +}; +pub use utils::{add_months, shift_date, today}; diff --git a/primitives/src/schedule/state.rs b/primitives/src/schedule/state.rs new file mode 100644 index 00000000..bf894c93 --- /dev/null +++ b/primitives/src/schedule/state.rs @@ -0,0 +1,62 @@ +use time::PrimitiveDateTime; + +use super::types::{ScheduleEvent, ScheduleMode, ScheduleResizeEdge}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ScheduleCapabilities { + pub(crate) events_drag_and_drop: bool, + pub(crate) drag_slot_select: bool, + pub(crate) event_resize: bool, +} + +impl ScheduleCapabilities { + pub(crate) fn new( + mode: ScheduleMode, + drag_drop: bool, + slot_select: bool, + resize: bool, + ) -> Self { + Self { + events_drag_and_drop: mode != ScheduleMode::Static && drag_drop, + drag_slot_select: slot_select, + event_resize: mode != ScheduleMode::Static && resize, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ScheduleSlotSelectionState { + pub(crate) anchor: PrimitiveDateTime, + pub(crate) current: PrimitiveDateTime, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ScheduleResizeState { + pub(crate) event: ScheduleEvent, + pub(crate) edge: ScheduleResizeEdge, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ScheduleSlotRange { + pub(crate) start: PrimitiveDateTime, + pub(crate) end: PrimitiveDateTime, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ScheduleExternalData { + pub(crate) format: String, + pub(crate) data: String, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ResizedEventTimes { + pub(crate) new_start: PrimitiveDateTime, + pub(crate) new_end: PrimitiveDateTime, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct LaidOutEvent { + pub(crate) event: ScheduleEvent, + pub(crate) column: usize, + pub(crate) columns: usize, +} diff --git a/primitives/src/schedule/tests.rs b/primitives/src/schedule/tests.rs new file mode 100644 index 00000000..28b61dcd --- /dev/null +++ b/primitives/src/schedule/tests.rs @@ -0,0 +1,860 @@ +use dioxus::prelude::*; +use time::macros::{date, time}; +use time::{Date, Duration, PrimitiveDateTime}; + +use super::state::ScheduleCapabilities; +use super::state::ScheduleSlotSelectionState; +use super::types::{ + ScheduleDropDestination, ScheduleEvent, ScheduleEventCreateSource, ScheduleEventRenderContext, + ScheduleLayout, ScheduleMode, ScheduleRecurrence, ScheduleRecurrenceExpansionLimit, + ScheduleRecurrenceFrequency, ScheduleResizeEdge, ScheduleTimeGridConfig, + ScheduleWeekViewConfig, +}; +use super::utils::{ + current_time_line_offset, expand_event, external_drop_data_from_formats, + filter_events_for_date, format_day_of_month_label, is_current_day, layout_overlapping_events, + month_event_segments_for_week, month_weekday_labels, resized_event_times, selection_contains, + slot_selection_range, timed_event_geometry, year_month_transition, +}; +use super::{add_months, shift_date, today}; + +fn event(id: &str, start: PrimitiveDateTime, end: PrimitiveDateTime) -> ScheduleEvent { + ScheduleEvent { + id: id.to_string(), + title: id.to_string(), + start, + end, + all_day: false, + color: None, + description: None, + recurrence: None, + drag_disabled: false, + resize_disabled: false, + } +} + +#[component] +fn ClearDraggingEventHarness() -> Element { + let dragging = use_signal(|| { + Some(event( + "dragged", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:00)), + )) + }); + + use_hook(move || { + super::components::clear_dragging_event(dragging); + }); + + rsx! { + div { "{dragging().is_some()}" } + } +} + +#[component] +fn ResponsiveMobileMonthMultiDayHarness() -> Element { + rsx! { + super::components::Schedule { + default_date: date!(2026 - 05 - 01), + default_view: super::types::ScheduleView::Month, + layout: ScheduleLayout::Responsive, + with_default_header: false, + events: vec![event( + "conference", + PrimitiveDateTime::new(date!(2026 - 05 - 04), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 07), time!(17:00)), + )], + render_event_body: Some(Callback::new(|context: ScheduleEventRenderContext| { + rsx! { + span { "data-render-date": context.date.to_string(), "{context.date}" } + } + })), + } + } +} + +#[component] +fn WeekTimedMultiDayHarness() -> Element { + rsx! { + super::components::Schedule { + default_date: date!(2026 - 05 - 05), + default_view: super::types::ScheduleView::Week, + layout: ScheduleLayout::Default, + with_default_header: false, + week_view: ScheduleWeekViewConfig { + time_grid: ScheduleTimeGridConfig { + start_hour: 8, + end_hour: 18, + slot_minutes: 60, + with_default_header: true, + }, + ..Default::default() + }, + events: vec![ + event( + "conference", + PrimitiveDateTime::new(date!(2026 - 05 - 04), time!(10:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 07), time!(12:00)), + ), + event( + "standup", + PrimitiveDateTime::new(date!(2026 - 05 - 05), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 05), time!(10:00)), + ), + ], + render_event_body: Some(Callback::new(|context: ScheduleEventRenderContext| { + rsx! { + span { "data-render-date": context.date.to_string(), "{context.event.id}" } + } + })), + } + } +} + +#[test] +fn recurrence_expansion_respects_limit() { + let mut event = event( + "daily", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:00)), + ); + event.recurrence = Some(ScheduleRecurrence { + frequency: ScheduleRecurrenceFrequency::Daily, + interval: 1, + count: Some(10), + until: None, + }); + + let expanded = expand_event( + &event, + ScheduleRecurrenceExpansionLimit { max_occurrences: 3 }, + ); + + assert_eq!(expanded.len(), 3); + assert_eq!(expanded[2].start.date(), date!(2026 - 05 - 03)); +} + +#[test] +fn event_filtering_includes_intersecting_events() { + let events = vec![ + event( + "overnight", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(23:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 02), time!(01:00)), + ), + event( + "other", + PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(10:00)), + ), + ]; + + let filtered = filter_events_for_date(&events, date!(2026 - 05 - 02)); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id, "overnight"); +} + +#[test] +fn month_event_segments_span_each_intersecting_week_once() { + let events = vec![event( + "conference", + PrimitiveDateTime::new(date!(2026 - 05 - 04), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 07), time!(17:00)), + )]; + let week = (4..=10) + .map(|day| date!(2026 - 05 - 01).replace_day(day).unwrap()) + .collect::>(); + + let segments = month_event_segments_for_week(&events, &week); + + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].event.id, "conference"); + assert_eq!(segments[0].start_column, 0); + assert_eq!(segments[0].column_span, 4); + assert_eq!(segments[0].start_date, date!(2026 - 05 - 04)); +} + +#[test] +fn month_event_segments_split_at_week_boundaries() { + let events = vec![event( + "trip", + PrimitiveDateTime::new(date!(2026 - 05 - 08), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 12), time!(17:00)), + )]; + let first_week = (4..=10) + .map(|day| date!(2026 - 05 - 01).replace_day(day).unwrap()) + .collect::>(); + let second_week = (11..=17) + .map(|day| date!(2026 - 05 - 01).replace_day(day).unwrap()) + .collect::>(); + + let first_segments = month_event_segments_for_week(&events, &first_week); + let second_segments = month_event_segments_for_week(&events, &second_week); + + assert_eq!(first_segments.len(), 1); + assert_eq!(first_segments[0].start_column, 4); + assert_eq!(first_segments[0].column_span, 3); + assert_eq!(first_segments[0].start_date, date!(2026 - 05 - 08)); + assert_eq!(second_segments.len(), 1); + assert_eq!(second_segments[0].start_column, 0); + assert_eq!(second_segments[0].column_span, 2); + assert_eq!(second_segments[0].start_date, date!(2026 - 05 - 11)); +} + +#[test] +fn month_event_segments_treat_midnight_end_as_exclusive() { + let events = vec![event( + "overnight", + PrimitiveDateTime::new(date!(2026 - 05 - 04), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 06), time!(00:00)), + )]; + let week = (4..=10) + .map(|day| date!(2026 - 05 - 01).replace_day(day).unwrap()) + .collect::>(); + + let segments = month_event_segments_for_week(&events, &week); + + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].start_column, 0); + assert_eq!(segments[0].column_span, 2); +} + +#[test] +fn static_mode_gates_drag_and_resize() { + let capabilities = ScheduleCapabilities::new(ScheduleMode::Static, true, true, true); + + assert!(!capabilities.events_drag_and_drop); + assert!(capabilities.drag_slot_select); + assert!(!capabilities.event_resize); +} + +#[test] +fn slot_selection_range_tracks_forward_and_reverse_drags() { + let nine = PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:00)); + let eleven = PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(11:00)); + + let forward = slot_selection_range( + ScheduleSlotSelectionState { + anchor: nine, + current: eleven, + }, + 30, + ); + let reverse = slot_selection_range( + ScheduleSlotSelectionState { + anchor: eleven, + current: nine, + }, + 30, + ); + + assert_eq!(forward.start, nine); + assert_eq!( + forward.end, + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(11:30)) + ); + assert_eq!(reverse, forward); + assert!(selection_contains( + ScheduleSlotSelectionState { + anchor: eleven, + current: nine, + }, + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:00)) + )); +} + +#[test] +fn all_day_create_payload_uses_full_day_range() { + let payload = super::components::all_day_event_create( + date!(2026 - 05 - 01), + super::types::ScheduleView::Month, + ScheduleEventCreateSource::DayClick, + ); + + assert_eq!( + payload.start, + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(00:00)) + ); + assert_eq!( + payload.end, + PrimitiveDateTime::new(date!(2026 - 05 - 02), time!(00:00)) + ); + assert_eq!(payload.date, date!(2026 - 05 - 01)); + assert!(payload.all_day); + assert_eq!(payload.source, ScheduleEventCreateSource::DayClick); +} + +#[test] +fn resize_computation_uses_edge_and_target_slot() { + let original = event( + "resize", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(11:00)), + ); + + let start_resize = resized_event_times( + &original, + ScheduleResizeEdge::Start, + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(08:30)), + 30, + ); + let end_resize = resized_event_times( + &original, + ScheduleResizeEdge::End, + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(12:00)), + 30, + ); + + assert_eq!( + start_resize.new_start, + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(08:30)) + ); + assert_eq!(start_resize.new_end, original.end); + assert_eq!(end_resize.new_start, original.start); + assert_eq!( + end_resize.new_end, + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(12:30)) + ); +} + +#[test] +fn resize_computation_preserves_minimum_duration() { + let original = event( + "resize", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(11:00)), + ); + + let start_resize = resized_event_times( + &original, + ScheduleResizeEdge::Start, + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(11:00)), + 30, + ); + let end_resize = resized_event_times( + &original, + ScheduleResizeEdge::End, + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(08:00)), + 30, + ); + + assert_eq!( + start_resize.new_start, + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:30)) + ); + assert_eq!( + end_resize.new_end, + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:30)) + ); +} + +#[test] +fn external_drop_data_prefers_useful_formats() { + let data = external_drop_data_from_formats(&[ + ("application/json", None), + ("text/plain", Some("plain".to_string())), + ("text/html", Some("html".to_string())), + ]) + .unwrap(); + + assert_eq!(data.format, "text/plain"); + assert_eq!(data.data, "plain"); + + assert_eq!( + external_drop_data_from_formats(&[("text/plain", Some(String::new()))]), + None + ); +} + +#[test] +fn build_event_drop_preserves_duration_and_destination() { + let original = event( + "dragged", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:30)), + ); + let new_start = PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(13:00)); + + let drop = super::components::build_event_drop( + original.clone(), + new_start, + date!(2026 - 05 - 03), + ScheduleDropDestination::Timed, + super::types::ScheduleView::Week, + None, + ); + + assert_eq!(drop.event_id, original.id); + assert_eq!(drop.event, original); + assert_eq!(drop.destination, ScheduleDropDestination::Timed); + assert_eq!(drop.new_start, new_start); + assert_eq!( + drop.new_end, + PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(14:30)) + ); + assert_eq!(drop.date, date!(2026 - 05 - 03)); + assert_eq!(drop.view, super::types::ScheduleView::Week); +} + +#[test] +fn build_event_drop_uses_slot_duration_for_all_day_to_timed_move() { + let mut original = event( + "all-day", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(00:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 02), time!(00:00)), + ); + original.all_day = true; + let new_start = PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(07:00)); + + let drop = super::components::build_event_drop( + original.clone(), + new_start, + date!(2026 - 05 - 03), + ScheduleDropDestination::Timed, + super::types::ScheduleView::Week, + Some(60), + ); + + assert_eq!(drop.new_start, new_start); + assert_eq!( + drop.new_end, + PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(08:00)) + ); +} + +#[test] +fn build_event_drop_without_slot_duration_preserves_all_day_duration() { + let mut original = event( + "all-day", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(00:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 02), time!(00:00)), + ); + original.all_day = true; + let new_start = PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(00:00)); + + let drop = super::components::build_event_drop( + original.clone(), + new_start, + date!(2026 - 05 - 03), + ScheduleDropDestination::Timed, + super::types::ScheduleView::Month, + None, + ); + + assert_eq!(drop.new_start, new_start); + assert_eq!( + drop.new_end, + PrimitiveDateTime::new(date!(2026 - 05 - 04), time!(00:00)) + ); +} + +#[test] +fn time_slot_drop_active_matches_only_exact_timed_drop_target() { + let dragged = event( + "dragged", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(12:00)), + ); + let drop_target = Some(format!( + "time-{}", + PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(11:00)) + )); + + assert!(super::components::time_slot_drop_active( + drop_target.clone(), + PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(11:00)), + 60, + Some(&dragged), + )); + assert!(!super::components::time_slot_drop_active( + drop_target.clone(), + PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(12:00)), + 60, + Some(&dragged), + )); + assert!(!super::components::time_slot_drop_active( + drop_target, + PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(13:00)), + 60, + Some(&dragged), + )); +} + +#[test] +fn timed_drop_preview_style_spans_dragged_event_duration_once() { + let dragged = event( + "dragged", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(12:30)), + ); + let days: Vec = (0..7) + .map(|offset| date!(2026 - 05 - 03) + Duration::days(offset)) + .collect(); + + let style = super::components::timed_drop_preview_style( + Some(format!( + "time-{}", + PrimitiveDateTime::new(date!(2026 - 05 - 05), time!(11:00)) + )), + &days, + ScheduleTimeGridConfig { + start_hour: 8, + end_hour: 18, + slot_minutes: 30, + with_default_header: true, + }, + Some(&dragged), + ) + .expect("active timed drop preview"); + + assert!(style.contains("top: calc(var(--schedule-time-slot-size) * 6.0000 + 2px);")); + assert!(style.contains("height: calc(var(--schedule-time-slot-size) * 5.0000 - 2px);")); + assert!(style.contains("left: calc(28.5714% + 4px);")); + assert!(style.contains("width: calc(14.2857% - 8px);")); +} + +#[test] +fn timed_drop_preview_style_spans_multi_day_dragged_events_once() { + let dragged = event( + "dragged", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 03), time!(12:00)), + ); + let days: Vec = (0..7) + .map(|offset| date!(2026 - 05 - 03) + Duration::days(offset)) + .collect(); + + let style = super::components::timed_drop_preview_style( + Some(format!( + "time-{}", + PrimitiveDateTime::new(date!(2026 - 05 - 05), time!(11:00)) + )), + &days, + ScheduleTimeGridConfig { + start_hour: 8, + end_hour: 18, + slot_minutes: 60, + with_default_header: true, + }, + Some(&dragged), + ) + .expect("active timed drop preview"); + + assert!(style.contains("top: calc(var(--schedule-time-slot-size) * 0.0000 + 2px);")); + assert!(style.contains("height: calc(var(--schedule-time-slot-size) * 11.0000 - 2px);")); + assert!(style.contains("left: calc(28.5714% + 4px);")); + assert!(style.contains("width: calc(42.8571% - 8px);")); + assert!(style.contains("pointer-events: auto;")); +} + +#[test] +fn timed_spanning_event_geometry_spans_visible_columns_once() { + let event = event( + "conference", + PrimitiveDateTime::new(date!(2026 - 05 - 04), time!(10:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 07), time!(12:00)), + ); + let days: Vec = (0..7) + .map(|offset| date!(2026 - 05 - 03) + Duration::days(offset)) + .collect(); + + let geometry = super::components::timed_spanning_event_geometry( + &event, + &days, + ScheduleTimeGridConfig { + start_hour: 8, + end_hour: 18, + slot_minutes: 60, + with_default_header: true, + }, + ) + .expect("visible timed spanning event"); + + assert_eq!(geometry.start_column, 1); + assert_eq!(geometry.column_span, 4); + assert_eq!(geometry.day_count, 7); + assert_eq!(geometry.start_date, date!(2026 - 05 - 04)); + assert_eq!(geometry.top_slots, 0.0); + assert_eq!(geometry.height_slots, 11.0); +} + +#[test] +fn timed_spanning_event_geometry_clips_to_visible_day_range() { + let event = event( + "conference", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 06), time!(12:00)), + ); + let days: Vec = (0..3) + .map(|offset| date!(2026 - 05 - 04) + Duration::days(offset)) + .collect(); + + let geometry = super::components::timed_spanning_event_geometry( + &event, + &days, + ScheduleTimeGridConfig { + start_hour: 8, + end_hour: 18, + slot_minutes: 60, + with_default_header: true, + }, + ) + .expect("visible clipped timed spanning event"); + + assert_eq!(geometry.start_column, 0); + assert_eq!(geometry.column_span, 3); + assert_eq!(geometry.day_count, 3); + assert_eq!(geometry.start_date, date!(2026 - 05 - 04)); + assert_eq!(geometry.top_slots, 0.0); + assert_eq!(geometry.height_slots, 11.0); +} + +#[test] +fn timed_spanning_event_geometry_ignores_single_day_events() { + let event = event( + "standup", + PrimitiveDateTime::new(date!(2026 - 05 - 05), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 05), time!(10:00)), + ); + let days: Vec = (0..7) + .map(|offset| date!(2026 - 05 - 03) + Duration::days(offset)) + .collect(); + + assert!(super::components::timed_spanning_event_geometry( + &event, + &days, + ScheduleTimeGridConfig::default(), + ) + .is_none()); +} + +#[test] +fn week_view_renders_timed_multi_day_event_as_single_spanning_node() { + let mut dom = VirtualDom::new(WeekTimedMultiDayHarness); + dom.rebuild_in_place(); + let html = dioxus_ssr::render(&dom); + + assert_eq!( + html.matches("data-schedule-event=\"conference\"").count(), + 1 + ); + assert_eq!(html.matches("data-schedule-event=\"standup\"").count(), 1); + assert!(html.contains("data-schedule-timed-spanning-events")); + assert!(html.contains("data-render-date=\"2026-05-04\"")); + assert!(html.contains("left: calc(14.2857% + 4px);")); + assert!(html.contains("width: calc(57.1429% - 8px);")); + assert!(html.contains("data-schedule-timed-events")); +} + +#[test] +fn clear_dragging_event_resets_stale_drag_state() { + let mut dom = VirtualDom::new(ClearDraggingEventHarness); + dom.rebuild_in_place(); + let html = dioxus_ssr::render(&dom); + + assert!(html.contains(">false<")); +} + +#[test] +fn responsive_mobile_month_renders_multi_day_events_on_each_intersecting_day() { + let mut dom = VirtualDom::new(ResponsiveMobileMonthMultiDayHarness); + dom.rebuild_in_place(); + let html = dioxus_ssr::render(&dom); + + assert_eq!( + html.matches("data-schedule-event=\"conference\"").count(), + 5 + ); + assert_eq!(html.matches("data-render-date=\"2026-05-04\"").count(), 2); + assert_eq!(html.matches("data-render-date=\"2026-05-05\"").count(), 1); + assert_eq!(html.matches("data-render-date=\"2026-05-06\"").count(), 1); + assert_eq!(html.matches("data-render-date=\"2026-05-07\"").count(), 1); +} + +#[test] +fn current_day_helper_compares_dates() { + assert!(is_current_day(today())); + assert!(!is_current_day(today() - Duration::days(1))); +} + +#[test] +fn current_time_line_offset_is_within_visible_hours() { + let now = PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:30)); + let offset = current_time_line_offset( + now, + super::types::ScheduleTimeGridConfig { + with_default_header: true, + start_hour: 8, + end_hour: 10, + slot_minutes: 30, + }, + ); + + assert_eq!(offset, Some(50.0)); +} + +#[test] +fn current_time_line_offset_excludes_times_outside_visible_hours() { + let config = super::types::ScheduleTimeGridConfig { + with_default_header: true, + start_hour: 8, + end_hour: 10, + slot_minutes: 30, + }; + + assert_eq!( + current_time_line_offset( + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(07:59)), + config + ), + None + ); + assert_eq!( + current_time_line_offset( + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(11:00)), + config + ), + None + ); +} + +#[test] +fn year_month_transition_clamps_day_and_keeps_year() { + let next = year_month_transition(date!(2024 - 01 - 31), 2); + + assert_eq!(next, date!(2024 - 02 - 29)); +} + +#[test] +fn add_months_clamps_to_last_day_of_target_month() { + assert_eq!(add_months(date!(2024 - 01 - 31), 1), date!(2024 - 02 - 29)); + assert_eq!(add_months(date!(2025 - 01 - 31), 1), date!(2025 - 02 - 28)); +} + +#[test] +fn shift_date_uses_schedule_view_granularity() { + let anchor = date!(2026 - 05 - 15); + + assert_eq!(shift_date(anchor, super::types::ScheduleView::Day, 2), date!(2026 - 05 - 17)); + assert_eq!(shift_date(anchor, super::types::ScheduleView::Week, -1), date!(2026 - 05 - 08)); + assert_eq!(shift_date(anchor, super::types::ScheduleView::Month, 1), date!(2026 - 06 - 15)); + assert_eq!(shift_date(anchor, super::types::ScheduleView::Year, 1), date!(2027 - 05 - 15)); +} + +#[test] +fn overlap_layout_assigns_distinct_columns() { + let laid_out = layout_overlapping_events(vec![ + event( + "a", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:00)), + ), + event( + "b", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:30)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:30)), + ), + ]); + + assert_eq!(laid_out.len(), 2); + assert_ne!(laid_out[0].column, laid_out[1].column); + assert_eq!(laid_out[0].columns, 2); +} + +#[test] +fn overlap_layout_uses_local_columns_per_collision_group() { + let laid_out = layout_overlapping_events(vec![ + event( + "a", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:00)), + ), + event( + "b", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:30)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:30)), + ), + event( + "c", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(12:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(13:00)), + ), + ]); + + assert_eq!(laid_out.len(), 3); + assert_eq!(laid_out[0].columns, 2); + assert_eq!(laid_out[1].columns, 2); + assert_eq!(laid_out[2].column, 0); + assert_eq!(laid_out[2].columns, 1); +} + +#[test] +fn timed_event_geometry_tracks_duration_and_clips_to_visible_range() { + let geometry = timed_event_geometry( + &event( + "visible", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(09:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(10:30)), + ), + date!(2026 - 05 - 01), + super::types::ScheduleTimeGridConfig { + with_default_header: true, + start_hour: 8, + end_hour: 10, + slot_minutes: 30, + }, + ) + .unwrap(); + + assert!((geometry.top_slots - 2.0).abs() < 0.001); + assert!((geometry.height_slots - 3.0).abs() < 0.001); + + let clipped = timed_event_geometry( + &event( + "clipped", + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(07:00)), + PrimitiveDateTime::new(date!(2026 - 05 - 01), time!(08:30)), + ), + date!(2026 - 05 - 01), + super::types::ScheduleTimeGridConfig { + with_default_header: true, + start_hour: 8, + end_hour: 10, + slot_minutes: 30, + }, + ) + .unwrap(); + + assert_eq!(clipped.top_slots, 0.0); + assert!((clipped.height_slots - 1.0).abs() < 0.001); +} + +#[test] +fn month_weekday_labels_follow_first_day_and_locale() { + let monday_first = month_weekday_labels(time::Weekday::Monday, "fr-FR"); + let sunday_first = month_weekday_labels(time::Weekday::Sunday, "en-US"); + + assert_eq!( + monday_first, + vec!["lun.", "mar.", "mer.", "jeu.", "ven.", "sam.", "dim."] + ); + assert_eq!( + sunday_first, + vec!["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + ); +} + +#[test] +fn month_weekday_labels_fallback_for_unknown_locale() { + assert_eq!( + month_weekday_labels(time::Weekday::Monday, "pt-BR"), + vec!["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + ); +} + +#[test] +fn month_day_labels_use_day_numbers_only() { + assert_eq!(format_day_of_month_label(date!(2026 - 05 - 18)), "18"); +} diff --git a/primitives/src/schedule/types.rs b/primitives/src/schedule/types.rs new file mode 100644 index 00000000..6cfffcbc --- /dev/null +++ b/primitives/src/schedule/types.rs @@ -0,0 +1,610 @@ +use dioxus::prelude::*; +use time::{Date, PrimitiveDateTime, Weekday}; + +use crate::schedule::utils::today; + +/// The visible schedule view. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ScheduleView { + /// A single-day time grid. + Day, + /// A seven-day time grid. + #[default] + Week, + /// A month grid. + Month, + /// A year grid of months. + Year, +} + +impl ScheduleView { + /// Returns the stable lowercase identifier used by schedule view controls and data attributes. + pub fn as_str(self) -> &'static str { + match self { + Self::Day => "day", + Self::Week => "week", + Self::Month => "month", + Self::Year => "year", + } + } +} + +/// The interaction mode for the schedule. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ScheduleMode { + /// Enables configured interactive behavior. + #[default] + Default, + /// Disables drag/drop and resize behavior even when those flags are enabled. + Static, +} + +/// The schedule layout strategy. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ScheduleLayout { + /// Render a single desktop-oriented view container. + #[default] + Default, + /// Render desktop and mobile containers with data attributes for responsive CSS. + Responsive, +} + +/// Recurrence frequency for a scheduled event. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ScheduleRecurrenceFrequency { + /// Repeat every day. + Daily, + /// Repeat every week. + Weekly, + /// Repeat every month, clamping invalid day-of-month values. + Monthly, + /// Repeat every year, clamping invalid day-of-month values. + Yearly, +} + +/// A recurrence rule attached to a [`ScheduleEvent`]. +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleRecurrence { + /// The recurrence frequency. + pub frequency: ScheduleRecurrenceFrequency, + /// The number of frequency units between occurrences. Values below one are treated as one. + pub interval: u32, + /// Stop expanding after this many occurrences, including the original event. + pub count: Option, + /// Stop expanding after occurrences that start after this date/time. + pub until: Option, +} + +/// A bounded recurrence expansion shape. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ScheduleRecurrenceExpansionLimit { + /// Maximum number of occurrences to create per recurring event. + pub max_occurrences: usize, +} + +impl Default for ScheduleRecurrenceExpansionLimit { + fn default() -> Self { + Self { + max_occurrences: 128, + } + } +} + +/// A schedule event. +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleEvent { + /// Stable event id. + pub id: String, + /// Event title displayed by the default renderer. + pub title: String, + /// Event start date/time. + pub start: PrimitiveDateTime, + /// Event end date/time. + pub end: PrimitiveDateTime, + /// Whether the event is an all-day event. + pub all_day: bool, + /// Optional color token exposed as `data-color`. + pub color: Option, + /// Optional description exposed as an accessible title. + pub description: Option, + /// Optional recurrence rule. + pub recurrence: Option, + /// Whether the event should reject drag/drop behavior. + pub drag_disabled: bool, + /// Whether the event should reject resize behavior. + pub resize_disabled: bool, +} + +/// Text labels used by the schedule. +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleLabels { + /// Previous date-range navigation label. + pub previous: String, + /// Next date-range navigation label. + pub next: String, + /// Today navigation label. + pub today: String, + /// Day view label. + pub day: String, + /// Week view label. + pub week: String, + /// Month view label. + pub month: String, + /// Year view label. + pub year: String, + /// All-day region label. + pub all_day: String, + /// Empty slot label. + pub empty_slot: String, +} + +impl Default for ScheduleLabels { + fn default() -> Self { + Self { + previous: "Previous".to_string(), + next: "Next".to_string(), + today: "Today".to_string(), + day: "Day".to_string(), + week: "Week".to_string(), + month: "Month".to_string(), + year: "Year".to_string(), + all_day: "All day".to_string(), + empty_slot: "No events".to_string(), + } + } +} + +/// Configuration shared by time-grid views. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleTimeGridConfig { + /// Whether to render the default header. + pub with_default_header: bool, + /// First visible hour. + pub start_hour: u8, + /// Last visible hour. + pub end_hour: u8, + /// Slot size in minutes. + pub slot_minutes: u8, +} + +impl Default for ScheduleTimeGridConfig { + fn default() -> Self { + Self { + with_default_header: true, + start_hour: 0, + end_hour: 23, + slot_minutes: 60, + } + } +} + +/// Day view configuration. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ScheduleDayViewConfig { + /// Time-grid configuration for the view. + pub time_grid: ScheduleTimeGridConfig, +} + +/// Week view configuration. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleWeekViewConfig { + /// Time-grid configuration for the view. + pub time_grid: ScheduleTimeGridConfig, + /// First day of the week. + pub first_day_of_week: Weekday, +} + +impl Default for ScheduleWeekViewConfig { + fn default() -> Self { + Self { + time_grid: ScheduleTimeGridConfig::default(), + first_day_of_week: Weekday::Sunday, + } + } +} + +/// Month view configuration. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleMonthViewConfig { + /// Whether to render the default header. + pub with_default_header: bool, + /// First day of the week. + pub first_day_of_week: Weekday, +} + +impl Default for ScheduleMonthViewConfig { + fn default() -> Self { + Self { + with_default_header: true, + first_day_of_week: Weekday::Sunday, + } + } +} + +/// Year view configuration. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleYearViewConfig { + /// Whether to render the default header. + pub with_default_header: bool, +} + +impl Default for ScheduleYearViewConfig { + fn default() -> Self { + Self { + with_default_header: true, + } + } +} + +/// Mobile month view configuration used by responsive layouts. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleMobileMonthViewConfig { + /// Whether to render the default header. + pub with_default_header: bool, +} + +impl Default for ScheduleMobileMonthViewConfig { + fn default() -> Self { + Self { + with_default_header: true, + } + } +} + +/// Event render context passed to custom render callbacks. +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleEventRenderContext { + /// The expanded event occurrence to render. + pub event: ScheduleEvent, + /// The active schedule view. + pub view: ScheduleView, + /// The date represented by the containing slot or cell. + pub date: Date, + /// Whether drag affordances are active for the event. + pub draggable: bool, + /// Whether resize affordances are active for the event. + pub resizable: bool, +} + +/// Date change callback payload. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleDateChange { + /// Previous date. + pub previous: Date, + /// Next date. + pub next: Date, + /// Active view when the date changed. + pub view: ScheduleView, +} + +/// View change callback payload. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleViewChange { + /// Previous view. + pub previous: ScheduleView, + /// Next view. + pub next: ScheduleView, + /// Active date when the view changed. + pub date: Date, +} + +/// Time slot click callback payload. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleTimeSlotClick { + /// Slot start. + pub start: PrimitiveDateTime, + /// Slot end. + pub end: PrimitiveDateTime, + /// Containing date. + pub date: Date, + /// Active view. + pub view: ScheduleView, +} + +/// All-day slot click callback payload. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleAllDaySlotClick { + /// Slot date. + pub date: Date, + /// Active view. + pub view: ScheduleView, +} + +/// Day click callback payload. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleDayClick { + /// Clicked date. + pub date: Date, + /// Active view. + pub view: ScheduleView, +} + +/// Interaction source that requested a new schedule event. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ScheduleEventCreateSource { + /// A timed slot was clicked. + TimeSlotClick, + /// A timed slot range was selected by dragging. + TimeSlotDrag, + /// An all-day slot was clicked. + AllDaySlotClick, + /// A day cell or day header was clicked. + DayClick, +} + +/// Event creation callback payload. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleEventCreate { + /// Requested event start. + pub start: PrimitiveDateTime, + /// Requested event end. + pub end: PrimitiveDateTime, + /// Containing date. + pub date: Date, + /// Whether the requested event is all-day. + pub all_day: bool, + /// Active view. + pub view: ScheduleView, + /// Interaction source that requested creation. + pub source: ScheduleEventCreateSource, +} + +/// Event click callback payload. +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleEventClick { + /// Clicked event occurrence. + pub event: ScheduleEvent, + /// Active view. + pub view: ScheduleView, +} + +/// Event drag lifecycle callback payload. +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleEventDrag { + /// Dragged event occurrence. + pub event: ScheduleEvent, + /// Active view. + pub view: ScheduleView, +} + +/// Destination surface for an event drop. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ScheduleDropDestination { + /// The event was dropped into the all-day row. + AllDay, + /// The event was dropped into a timed slot or other non-all-day surface. + Timed, +} + +/// Event drop callback payload. +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleEventDrop { + /// Dropped event id. + pub event_id: String, + /// Original event occurrence. + pub event: ScheduleEvent, + /// Destination surface that accepted the drop. + pub destination: ScheduleDropDestination, + /// New start. + pub new_start: PrimitiveDateTime, + /// New end. + pub new_end: PrimitiveDateTime, + /// Target date. + pub date: Date, + /// Active view. + pub view: ScheduleView, +} + +/// External item drop callback payload. +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleExternalDrop { + /// Browser-provided data transfer payload when available. + /// + /// Dioxus exposes the browser `DataTransfer` strings that the drag source + /// allows for the current drop. The schedule checks common formats in this + /// order: `application/json`, `text/plain`, `text/uri-list`, and `text/html`. + /// Browser security rules and non-web renderers can still make external + /// drops unavailable; in that case this field is `None`. + pub data: Option, + /// The MIME type that produced [`ScheduleExternalDrop::data`]. + pub data_format: Option, + /// Target start. + pub start: PrimitiveDateTime, + /// Target end. + pub end: PrimitiveDateTime, + /// Target date. + pub date: Date, + /// Active view. + pub view: ScheduleView, +} + +/// Slot range selection callback payload. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduleSlotRangeSelection { + /// Selected range start. + pub start: PrimitiveDateTime, + /// Selected range end. + pub end: PrimitiveDateTime, + /// Active view. + pub view: ScheduleView, +} + +/// Edge used during event resize. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ScheduleResizeEdge { + /// Resize from the event start. + Start, + /// Resize from the event end. + End, +} + +/// Event resize callback payload. +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleEventResize { + /// Resized event id. + pub event_id: String, + /// Original event occurrence. + pub event: ScheduleEvent, + /// New start. + pub new_start: PrimitiveDateTime, + /// New end. + pub new_end: PrimitiveDateTime, + /// Resized edge. + pub edge: ScheduleResizeEdge, + /// Active view. + pub view: ScheduleView, +} + +/// Class names applied to schedule surfaces. +/// +/// The primitive also exposes state through `data-*` attributes. These classes +/// provide an API-level styling surface for consumers that prefer stable class +/// hooks over attribute selectors. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ScheduleClassNames { + /// Class applied to the desktop view container. + pub desktop_view: String, + /// Class applied to the responsive mobile view container. + pub mobile_view: String, + /// Class applied to day view sections. + pub day_view: String, + /// Class applied to week view sections. + pub week_view: String, + /// Class applied to month view sections. + pub month_view: String, + /// Class applied to year view sections. + pub year_view: String, + /// Class applied to mobile month view sections. + pub mobile_month_view: String, + /// Class applied to time slot buttons. + pub time_slot: String, + /// Class applied to all-day slot buttons. + pub all_day_slot: String, + /// Class applied to month day cells. + pub month_day: String, + /// Class applied to event nodes. + pub event: String, +} + +/// The props for the [`Schedule`](crate::schedule::components::Schedule) component. +#[derive(Props, Clone, PartialEq)] +pub struct ScheduleProps { + /// Controlled active date. + #[props(default)] + pub date: ReadSignal>, + /// Default active date for uncontrolled usage. + #[props(default = today())] + pub default_date: Date, + /// Callback fired after the active date changes. + #[props(default)] + pub on_date_change: Callback, + /// Controlled active view. + #[props(default)] + pub view: ReadSignal>, + /// Default active view for uncontrolled usage. + #[props(default)] + pub default_view: ScheduleView, + /// Callback fired after the active view changes. + #[props(default)] + pub on_view_change: Callback, + /// Interaction mode. + #[props(default)] + pub mode: ScheduleMode, + /// Layout strategy. + #[props(default)] + pub layout: ScheduleLayout, + /// Events to render. + #[props(default)] + pub events: Vec, + /// Recurrence expansion limit. + #[props(default)] + pub recurrence_expansion_limit: ScheduleRecurrenceExpansionLimit, + /// Locale identifier exposed as `data-locale`. + #[props(default = "en-US".to_string())] + pub locale: String, + /// Text labels. + #[props(default)] + pub labels: ScheduleLabels, + /// Day view configuration. + #[props(default)] + pub day_view: ScheduleDayViewConfig, + /// Week view configuration. + #[props(default)] + pub week_view: ScheduleWeekViewConfig, + /// Month view configuration. + #[props(default)] + pub month_view: ScheduleMonthViewConfig, + /// Year view configuration. + #[props(default)] + pub year_view: ScheduleYearViewConfig, + /// Mobile month view configuration. + #[props(default)] + pub mobile_month_view: ScheduleMobileMonthViewConfig, + /// Whether to render the top-level default schedule header. + #[props(default = true)] + pub with_default_header: bool, + /// Custom top-level header content. When supplied, this replaces the default + /// schedule header regardless of [`ScheduleProps::with_default_header`]. + #[props(default)] + pub header: Option, + /// Radius token exposed through `--dxc-schedule-radius` for style layers. + #[props(default)] + pub radius: Option, + /// Class names for stable schedule styling hooks. + #[props(default)] + pub class_names: ScheduleClassNames, + /// Enable event drag/drop. Ignored in [`ScheduleMode::Static`]. + #[props(default)] + pub with_events_drag_and_drop: bool, + /// Enable drag slot selection. + #[props(default)] + pub with_drag_slot_select: bool, + /// Enable event resize. Ignored in [`ScheduleMode::Static`]. + #[props(default)] + pub with_event_resize: bool, + /// Event drag guard. + #[props(default = Callback::new(|event: ScheduleEvent| !event.drag_disabled))] + pub can_drag_event: Callback, + /// Event resize guard. + #[props(default = Callback::new(|event: ScheduleEvent| !event.resize_disabled))] + pub can_resize_event: Callback, + /// Custom event body renderer. The default renderer shows title and time. + #[props(default)] + pub render_event_body: Option>, + /// Called when a time slot is clicked. + #[props(default)] + pub on_time_slot_click: Callback, + /// Called when an all-day slot is clicked. + #[props(default)] + pub on_all_day_slot_click: Callback, + /// Called when a day cell is clicked. + #[props(default)] + pub on_day_click: Callback, + /// Called when an empty schedule slot requests event creation. + #[props(default)] + pub on_event_create: Callback, + /// Called when an event is clicked. + #[props(default)] + pub on_event_click: Callback, + /// Called when event dragging starts. + #[props(default)] + pub on_event_drag_start: Callback, + /// Called when event dragging ends. + #[props(default)] + pub on_event_drag_end: Callback, + /// Called when an event is dropped on a schedule target. + #[props(default)] + pub on_event_drop: Callback, + /// Called when external data is dropped on a schedule target. + #[props(default)] + pub on_external_event_drop: Callback, + /// Called when drag slot selection completes. + #[props(default)] + pub on_slot_drag_end: Callback, + /// Called when an event resize completes. + #[props(default)] + pub on_event_resize: Callback, + /// Additional attributes to apply to the schedule root. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} diff --git a/primitives/src/schedule/utils.rs b/primitives/src/schedule/utils.rs new file mode 100644 index 00000000..b1d48cf6 --- /dev/null +++ b/primitives/src/schedule/utils.rs @@ -0,0 +1,487 @@ +use std::cmp::Ordering; + +use dioxus::prelude::*; +use time::{Date, Duration, Month, PrimitiveDateTime, Time, Weekday}; + +use crate::LocalDateExt as _; + +use super::state::{ + LaidOutEvent, ResizedEventTimes, ScheduleExternalData, ScheduleSlotRange, + ScheduleSlotSelectionState, +}; +use super::types::{ + ScheduleEvent, ScheduleRecurrenceExpansionLimit, ScheduleRecurrenceFrequency, + ScheduleResizeEdge, ScheduleTimeGridConfig, ScheduleView, +}; + +/// Returns the current local date, falling back to UTC when local time is unavailable. +pub fn today() -> Date { + time::OffsetDateTime::now_local_date() +} + +pub(crate) fn now() -> PrimitiveDateTime { + let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + PrimitiveDateTime::new(now.date(), now.time()) +} + +/// Shifts a schedule anchor date by the number of ranges represented by the active view. +pub fn shift_date(date: Date, view: ScheduleView, amount: i64) -> Date { + match view { + ScheduleView::Day => date + Duration::days(amount), + ScheduleView::Week => date + Duration::weeks(amount), + ScheduleView::Month => add_months(date, amount as i32), + ScheduleView::Year => add_months(date, (amount * 12) as i32), + } +} + +pub(crate) fn week_dates(date: Date, first_day: Weekday) -> Vec { + let offset = (7 + date.weekday().number_days_from_monday() as i64 + - first_day.number_days_from_monday() as i64) + % 7; + let start = date - Duration::days(offset); + (0..7).map(|day| start + Duration::days(day)).collect() +} + +pub(crate) fn month_grid_dates(date: Date, first_day: Weekday) -> Vec { + let first = Date::from_calendar_date(date.year(), date.month(), 1).unwrap(); + let offset = (7 + first.weekday().number_days_from_monday() as i64 + - first_day.number_days_from_monday() as i64) + % 7; + let start = first - Duration::days(offset); + (0..42).map(|day| start + Duration::days(day)).collect() +} + +pub(crate) fn time_slots(config: ScheduleTimeGridConfig) -> Vec