From 73c45016df5a41d0fd8c314800a3ec8caa4f4049 Mon Sep 17 00:00:00 2001 From: Saren Date: Fri, 29 May 2026 14:39:58 -0700 Subject: [PATCH 1/3] add use_scroll_spy() hook + TableOfContents comp --- .../table_of_contents/component.json | 13 + .../components/table_of_contents/component.rs | 19 + .../src/components/table_of_contents/docs.md | 13 + .../src/components/table_of_contents/mod.rs | 2 + .../components/table_of_contents/style.css | 33 ++ .../table_of_contents/variants/main/mod.rs | 132 ++++++ primitives/src/lib.rs | 2 + primitives/src/scroll_spy.rs | 394 ++++++++++++++++++ primitives/src/table_of_contents.rs | 112 +++++ 9 files changed, 720 insertions(+) create mode 100644 preview/src/components/table_of_contents/component.json create mode 100644 preview/src/components/table_of_contents/component.rs create mode 100644 preview/src/components/table_of_contents/docs.md create mode 100644 preview/src/components/table_of_contents/mod.rs create mode 100644 preview/src/components/table_of_contents/style.css create mode 100644 preview/src/components/table_of_contents/variants/main/mod.rs create mode 100644 primitives/src/scroll_spy.rs create mode 100644 primitives/src/table_of_contents.rs 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 000000000..74a031a8d --- /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 000000000..cefbb1ce3 --- /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 000000000..0b47e963d --- /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 000000000..2590c0132 --- /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 000000000..202c2e20b --- /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 000000000..9753e5d76 --- /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/primitives/src/lib.rs b/primitives/src/lib.rs index facfc1e63..6b3dea8ab 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -41,12 +41,14 @@ mod portal; pub mod progress; pub mod radio_group; pub mod scroll_area; +pub mod scroll_spy; pub mod select; mod selectable; mod selection; pub mod separator; pub mod slider; pub mod switch; +pub mod table_of_contents; pub mod tabs; pub mod toast; pub mod toggle; diff --git a/primitives/src/scroll_spy.rs b/primitives/src/scroll_spy.rs new file mode 100644 index 000000000..fd05a5b30 --- /dev/null +++ b/primitives/src/scroll_spy.rs @@ -0,0 +1,394 @@ +//! Scroll spy state and helpers for tracking active document headings. + +use dioxus::prelude::*; +use serde::Deserialize; + +/// Options used to configure [`use_scroll_spy`]. +#[derive(Clone, PartialEq)] +pub struct ScrollSpyOptions { + /// CSS selector used to find heading elements. + pub selector: String, + + /// Viewport offset used when selecting the active heading. + pub offset: f64, + + /// Scroll container that emits scroll updates. + pub scroll_host: ScrollSpyScrollHost, + + /// Data to expose before browser-side heading discovery runs. + pub initial_data: Vec, +} + +impl Default for ScrollSpyOptions { + fn default() -> Self { + Self { + selector: "h1, h2, h3, h4, h5, h6".to_string(), + offset: 0.0, + scroll_host: ScrollSpyScrollHost::Window, + initial_data: Vec::new(), + } + } +} + +/// Scroll container used by [`use_scroll_spy`]. +#[derive(Clone, PartialEq, Eq)] +pub enum ScrollSpyScrollHost { + /// Listen to the browser window. + Window, + + /// Listen to the first element matching the CSS selector. + Selector(String), +} + +/// Data for one heading tracked by [`use_scroll_spy`]. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct ScrollSpyData { + /// Stable id used by table-of-contents controls. + pub id: String, + + /// Human-readable heading text. + pub value: String, + + /// Heading depth, where `h1` is `1` and `h6` is `6`. + pub depth: u8, +} + +/// State returned by [`use_scroll_spy`]. +#[derive(Clone, Copy)] +pub struct ScrollSpyState { + /// Index of the currently active heading. + pub active: Signal>, + + /// Heading data discovered by the hook. + pub data: Signal>, + + /// Whether browser-side heading discovery has run. + pub initialized: Signal, + + /// Re-query headings and reattach scroll listeners. + pub reinitialize: Callback<()>, +} + +/// Track headings in the current document and expose the active heading index. +pub fn use_scroll_spy(options: ScrollSpyOptions) -> ScrollSpyState { + #[allow(unused_mut)] + let mut active = use_signal(|| active_from_positions(&[])); + #[allow(unused_mut)] + let mut data = use_signal(|| options.initial_data.clone()); + #[allow(unused_mut)] + let mut initialized = use_signal(|| false); + let mut reinitialize_tick = use_signal(|| 0usize); + + let reinitialize = use_callback(move |()| { + reinitialize_tick.with_mut(|tick| *tick += 1); + }); + + client! { + let effect_options = options.clone(); + crate::use_effect_with_cleanup(move || { + let _ = reinitialize_tick(); + let selector = effect_options.selector.clone(); + let offset = effect_options.offset; + let host_selector = match &effect_options.scroll_host { + ScrollSpyScrollHost::Window => None, + ScrollSpyScrollHost::Selector(selector) => Some(selector.clone()), + }; + + let mut eval = document::eval( + r#" + const selector = await dioxus.recv(); + const offset = await dioxus.recv(); + const hostSelector = await dioxus.recv(); + const host = hostSelector ? document.querySelector(hostSelector) : window; + if (!host) { + dioxus.send({ kind: "initialized", data: [], active: null }); + return; + } + + let headings = []; + let frame = null; + + function headingDepth(element) { + const match = /^H([1-6])$/.exec(element.tagName || ""); + return match ? Number(match[1]) : 0; + } + + function readHeadings() { + const root = host === window ? document : host; + headings = Array.from(root.querySelectorAll(selector)).filter( + (element) => headingDepth(element) > 0 + ); + return headings.map((element, index) => ({ + tag_name: element.tagName || "", + id: element.id || `dxc-scroll-spy-${index}`, + value: element.textContent || "" + })); + } + + function activeIndex() { + if (headings.length === 0) { + return null; + } + + const positions = []; + const hostTop = host === window ? 0 : host.getBoundingClientRect().top; + for (let index = 0; index < headings.length; index += 1) { + const position = headings[index].getBoundingClientRect().top - hostTop - offset; + positions.push(position); + } + + return activeIndexFromPositions(positions); + } + + function activeIndexFromPositions(positions) { + for (let index = positions.length - 1; index >= 0; index -= 1) { + if (positions[index] <= 0) { + return index; + } + } + + return positions.length > 0 ? 0 : null; + } + + function publish(kind) { + dioxus.send({ + kind, + data: readHeadings(), + active: activeIndex() + }); + } + + publish("initialized"); + const onScroll = () => publish("scroll"); + host.addEventListener("scroll", onScroll, { passive: true }); + if (host !== window) { + window.addEventListener("resize", onScroll, { passive: true }); + } + + await dioxus.recv(); + host.removeEventListener("scroll", onScroll); + if (host !== window) { + window.removeEventListener("resize", onScroll); + } + "#, + ); + let _ = eval.send(selector); + let _ = eval.send(offset); + let _ = eval.send(host_selector); + + spawn(async move { + while let Ok(message) = eval.recv::().await { + data.set( + message + .data + .into_iter() + .filter_map(scroll_spy_data_from_match) + .collect(), + ); + active.set(message.active); + if message.kind == "initialized" { + initialized.set(true); + } + } + }); + + move || { + let _ = eval.send(true); + } + }); + } + + ScrollSpyState { + active, + data, + initialized, + reinitialize, + } +} + +#[derive(Deserialize)] +#[allow(dead_code)] +struct ScrollSpyMessage { + kind: String, + data: Vec, + active: Option, +} + +#[derive(Deserialize)] +struct ScrollSpyMatch { + tag_name: String, + id: String, + value: String, +} + +pub(crate) fn active_from_positions(positions: &[f64]) -> Option { + positions + .iter() + .rposition(|position| *position <= 0.0) + .or_else(|| if positions.is_empty() { None } else { Some(0) }) +} + +pub(crate) fn positions_from_tops(heading_tops: &[f64], host_top: f64, offset: f64) -> Vec { + heading_tops + .iter() + .map(|heading_top| heading_top - host_top - offset) + .collect() +} + +#[allow(dead_code)] +pub(crate) fn heading_depth(tag_name: &str) -> u8 { + let mut chars = tag_name.chars(); + match (chars.next(), chars.next(), chars.next()) { + (Some('h' | 'H'), Some(depth @ '1'..='6'), None) => depth as u8 - b'0', + _ => 0, + } +} + +fn scroll_spy_data_from_match(matched: ScrollSpyMatch) -> Option { + let depth = heading_depth(&matched.tag_name); + (depth > 0).then_some(ScrollSpyData { + id: matched.id, + value: matched.value, + depth, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heading_depth_reads_heading_tags() { + assert_eq!(heading_depth("H1"), 1); + assert_eq!(heading_depth("h4"), 4); + assert_eq!(heading_depth("div"), 0); + assert_eq!(heading_depth("H7"), 0); + } + + #[test] + fn selector_matches_can_filter_out_non_heading_tags() { + let matches = [ + ScrollSpyMatch { + tag_name: "div".to_string(), + id: "ignored".to_string(), + value: "Ignored".to_string(), + }, + ScrollSpyMatch { + tag_name: "H1".to_string(), + id: "intro".to_string(), + value: "Intro".to_string(), + }, + ScrollSpyMatch { + tag_name: "section".to_string(), + id: "ignored-2".to_string(), + value: "Ignored Again".to_string(), + }, + ScrollSpyMatch { + tag_name: "h3".to_string(), + id: "details".to_string(), + value: "Details".to_string(), + }, + ]; + let data = matches + .into_iter() + .filter_map(scroll_spy_data_from_match) + .collect::>(); + + assert_eq!( + data, + vec![ + ScrollSpyData { + id: "intro".to_string(), + value: "Intro".to_string(), + depth: 1, + }, + ScrollSpyData { + id: "details".to_string(), + value: "Details".to_string(), + depth: 3, + }, + ] + ); + } + + #[test] + fn non_heading_match_does_not_become_scroll_spy_data() { + let matched = ScrollSpyMatch { + tag_name: "nav".to_string(), + id: "toc".to_string(), + value: "Table of contents".to_string(), + }; + + assert_eq!(scroll_spy_data_from_match(matched), None); + } + + #[test] + fn active_from_positions_returns_none_for_empty_list() { + assert_eq!(active_from_positions(&[]), None); + } + + #[test] + fn active_from_positions_returns_last_heading_at_or_above_offset_else_first_upcoming() { + assert_eq!(active_from_positions(&[-120.0, -12.0, 80.0]), Some(1)); + assert_eq!(active_from_positions(&[20.0, -4.0, 6.0]), Some(1)); + } + + #[test] + fn active_from_positions_prefers_latest_heading_at_or_above_offset() { + assert_eq!(active_from_positions(&[-160.0, -20.0, 420.0]), Some(1)); + } + + #[test] + fn active_from_positions_falls_back_to_first_upcoming_heading() { + assert_eq!(active_from_positions(&[30.0, 180.0]), Some(0)); + } + + #[test] + fn host_relative_positions_activate_later_heading_before_viewport_top() { + let viewport_relative = positions_from_tops(&[40.0, 280.0, 500.0], 0.0, 88.0); + let host_relative = positions_from_tops(&[40.0, 280.0, 500.0], 200.0, 88.0); + + assert_eq!(viewport_relative, vec![-48.0, 192.0, 412.0]); + assert_eq!(host_relative, vec![-248.0, -8.0, 212.0]); + assert_eq!(active_from_positions(&viewport_relative), Some(0)); + assert_eq!(active_from_positions(&host_relative), Some(1)); + } + + #[test] + fn host_relative_positions_remove_first_heading_stall_during_scroll() { + let heading_tops_by_frame = [ + [120.0, 360.0, 600.0], + [40.0, 280.0, 520.0], + [-20.0, 220.0, 460.0], + [-80.0, 160.0, 400.0], + ]; + + let viewport_active = heading_tops_by_frame + .iter() + .map(|tops| active_from_positions(&positions_from_tops(tops, 0.0, 88.0))) + .collect::>(); + let host_active = heading_tops_by_frame + .iter() + .map(|tops| active_from_positions(&positions_from_tops(tops, 200.0, 88.0))) + .collect::>(); + + assert_eq!(viewport_active, vec![Some(0), Some(0), Some(0), Some(0)]); + assert_eq!(host_active, vec![Some(0), Some(1), Some(1), Some(1)]); + } + + #[test] + fn options_preserve_initial_data() { + let data = vec![ScrollSpyData { + id: "intro".to_string(), + value: "Intro".to_string(), + depth: 1, + }]; + + let options = ScrollSpyOptions { + initial_data: data.clone(), + ..Default::default() + }; + + assert_eq!(options.initial_data, data); + } +} diff --git a/primitives/src/table_of_contents.rs b/primitives/src/table_of_contents.rs new file mode 100644 index 000000000..346ae7dc7 --- /dev/null +++ b/primitives/src/table_of_contents.rs @@ -0,0 +1,112 @@ +//! Defines the [`TableOfContents`] component. + +use dioxus::prelude::*; + +use crate::scroll_spy::{use_scroll_spy, ScrollSpyData, ScrollSpyOptions}; + +/// Props for the [`TableOfContents`] component. +#[derive(Props, Clone, PartialEq)] +pub struct TableOfContentsProps { + /// Options passed to [`use_scroll_spy`]. + #[props(default)] + pub scroll_spy_options: ScrollSpyOptions, + + /// Data rendered before browser-side heading discovery completes. + #[props(default)] + pub initial_data: Vec, + + /// Minimum heading depth before indentation starts. + #[props(default = 1)] + pub min_depth_to_offset: u8, + + /// CSS length multiplied by each heading depth level. + #[props(default = "20px".to_string())] + pub depth_offset: String, + + /// Additional attributes to apply to the table-of-contents root element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} + +/// # TableOfContents +/// +/// The `TableOfContents` component renders anchors for headings discovered by +/// [`use_scroll_spy`]. The active anchor receives `data-active="true"`. +#[component] +pub fn TableOfContents(props: TableOfContentsProps) -> Element { + let mut options = props.scroll_spy_options.clone(); + if options.initial_data.is_empty() { + options.initial_data = props.initial_data.clone(); + } + + let spy = use_scroll_spy(options); + let min_depth = props.min_depth_to_offset; + let depth_offset = props.depth_offset.clone(); + + rsx! { + nav { + "data-table-of-contents": "true", + ..props.attributes, + for (index, item) in (spy.data)().into_iter().enumerate() { + TableOfContentsControl { + key: "{item.id}", + item, + active: (spy.active)() == Some(index), + min_depth, + depth_offset: depth_offset.clone(), + } + } + } + } +} + +#[component] +fn TableOfContentsControl( + item: ScrollSpyData, + active: bool, + min_depth: u8, + depth_offset: String, +) -> Element { + let depth = item.depth.saturating_sub(min_depth); + let style = format!("--depth: {depth}; --depth-offset: {depth_offset};"); + + rsx! { + a { + href: "#{item.id}", + style, + "data-active": if active { "true" } else { "false" }, + "data-depth": "{item.depth}", + "{item.value}" + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_of_contents_renders_initial_data_on_server() { + let rendered = dioxus_ssr::render_element(rsx! { + TableOfContents { + initial_data: vec![ + ScrollSpyData { + id: "intro".to_string(), + value: "Intro".to_string(), + depth: 1, + }, + ScrollSpyData { + id: "details".to_string(), + value: "Details".to_string(), + depth: 2, + }, + ], + } + }); + + assert!(rendered.contains("href=\"#intro\"")); + assert!(rendered.contains("Intro")); + assert!(rendered.contains("href=\"#details\"")); + assert!(rendered.contains("Details")); + } +} From 859833c819df5a535a1666b9fc3ec8e220798200 Mon Sep 17 00:00:00 2001 From: Saren Date: Tue, 2 Jun 2026 12:48:58 -0700 Subject: [PATCH 2/3] fix(clippy): address workspace warnings --- preview/src/main.rs | 20 ++++++++++++++++---- primitives/src/scroll_spy.rs | 20 +++++++++++--------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/preview/src/main.rs b/preview/src/main.rs index c5fb6f408..2b20dfe45 100644 --- a/preview/src/main.rs +++ b/preview/src/main.rs @@ -1266,11 +1266,23 @@ fn WidgetMasonry() -> Element { } } -#[allow(unpredictable_function_pointer_comparisons)] +#[derive(Props, Clone)] +struct MasonryCardProps { + component: fn() -> Element, + #[props(default)] + popout: bool, +} + +impl PartialEq for MasonryCardProps { + fn eq(&self, other: &Self) -> bool { + self.popout == other.popout && std::ptr::fn_addr_eq(self.component, other.component) + } +} + #[component] -fn MasonryCard(component: fn() -> Element, #[props(default)] popout: bool) -> Element { - let Comp = component; - let class = if popout { +fn MasonryCard(props: MasonryCardProps) -> Element { + let Comp = props.component; + let class = if props.popout { "dx-widget-card dx-widget-card-popout" } else { "dx-widget-card" diff --git a/primitives/src/scroll_spy.rs b/primitives/src/scroll_spy.rs index fd05a5b30..98e046b85 100644 --- a/primitives/src/scroll_spy.rs +++ b/primitives/src/scroll_spy.rs @@ -207,7 +207,7 @@ pub fn use_scroll_spy(options: ScrollSpyOptions) -> ScrollSpyState { } #[derive(Deserialize)] -#[allow(dead_code)] +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] struct ScrollSpyMessage { kind: String, data: Vec, @@ -215,6 +215,7 @@ struct ScrollSpyMessage { } #[derive(Deserialize)] +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] struct ScrollSpyMatch { tag_name: String, id: String, @@ -225,14 +226,7 @@ pub(crate) fn active_from_positions(positions: &[f64]) -> Option { positions .iter() .rposition(|position| *position <= 0.0) - .or_else(|| if positions.is_empty() { None } else { Some(0) }) -} - -pub(crate) fn positions_from_tops(heading_tops: &[f64], host_top: f64, offset: f64) -> Vec { - heading_tops - .iter() - .map(|heading_top| heading_top - host_top - offset) - .collect() + .or(if positions.is_empty() { None } else { Some(0) }) } #[allow(dead_code)] @@ -244,6 +238,7 @@ pub(crate) fn heading_depth(tag_name: &str) -> u8 { } } +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] fn scroll_spy_data_from_match(matched: ScrollSpyMatch) -> Option { let depth = heading_depth(&matched.tag_name); (depth > 0).then_some(ScrollSpyData { @@ -257,6 +252,13 @@ fn scroll_spy_data_from_match(matched: ScrollSpyMatch) -> Option mod tests { use super::*; + fn positions_from_tops(heading_tops: &[f64], host_top: f64, offset: f64) -> Vec { + heading_tops + .iter() + .map(|heading_top| heading_top - host_top - offset) + .collect() + } + #[test] fn heading_depth_reads_heading_tags() { assert_eq!(heading_depth("H1"), 1); From 77d8b5e6ab79f0c473f3dd79ddef08d871a87ebb Mon Sep 17 00:00:00 2001 From: Saren Date: Tue, 2 Jun 2026 12:56:41 -0700 Subject: [PATCH 3/3] fix(table-of-contents): register component --- component.json | 1 + preview/src/components/mod.rs | 1 + preview/src/components/table_of_contents/style.css | 6 +++--- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/component.json b/component.json index c09861149..4b2c22e91 100644 --- a/component.json +++ b/component.json @@ -3,6 +3,7 @@ "description": "", "members": [ "preview/src/components/tabs", + "preview/src/components/table_of_contents", "preview/src/components/dropdown_menu", "preview/src/components/navbar", "preview/src/components/form", diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index cad937b4d..eeb5d6a82 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -206,6 +206,7 @@ examples!( slider[dynamic_range, range], switch, tabs, + table_of_contents, textarea[outline, fade, ghost], toast, toggle, diff --git a/preview/src/components/table_of_contents/style.css b/preview/src/components/table_of_contents/style.css index 202c2e20b..d4ef696b3 100644 --- a/preview/src/components/table_of_contents/style.css +++ b/preview/src/components/table_of_contents/style.css @@ -1,16 +1,16 @@ .dx-table-of-contents { display: flex; - flex-direction: column; min-width: 14rem; + flex-direction: column; font-size: 0.875rem; } .dx-table-of-contents a { - border-left: 2px solid var(--primary-color-6); + padding: 0.5rem 0.75rem 0.5rem calc(0.875rem + var(--depth) * var(--depth-offset)); border-radius: 0 0.625rem 0.625rem 0; + border-left: 2px solid var(--primary-color-6); 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,