fc-dioxus 0.1.2

Typed, accessible Dioxus components over future-css
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! Host access stays here so components keep renderer-independent signatures.
//!
//! Eval-capable hosts use a fixed script and a data channel. SSR and native hosts
//! report `Unsupported`, allowing callers to keep their native markup fallback.

use super::{attrs::ThemePairs, id::Id};
use dioxus::prelude::*;
use std::fmt;

/// Capabilities detected after mount, never used to change the initial tree.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Capabilities {
    /// The host implements modal dialogs.
    pub modal: bool,
    /// The host implements the popover API.
    pub popover: bool,
    /// The host implements clipboard writes.
    pub clipboard: bool,
}

/// A user preference the host reports through a media query. Fixed queries only, so no
/// caller can pass the bridge arbitrary text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MediaPreference {
    /// `prefers-color-scheme: dark`.
    DarkScheme,
    /// `prefers-contrast: more`.
    MoreContrast,
    /// `forced-colors: active`.
    ForcedColors,
}
impl MediaPreference {
    const fn query(self) -> &'static str {
        match self {
            Self::DarkScheme => "(prefers-color-scheme: dark)",
            Self::MoreContrast => "(prefers-contrast: more)",
            Self::ForcedColors => "(forced-colors: active)",
        }
    }
}

/// A user preference as it changes, from [`DomBridge::watch`].
pub struct PreferenceWatch {
    eval: document::Eval,
    started: bool,
}
impl PreferenceWatch {
    /// The next value, or `None` when the host cannot report it.
    pub async fn next(&mut self) -> Option<bool> {
        if !self.started {
            return None;
        }
        self.eval.recv::<bool>().await.ok()
    }
}

/// A host operation could not be performed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DomError {
    /// SSR, native, or a host without the requested capability.
    Unsupported,
    /// The target ID does not exist.
    MissingTarget,
    /// The host rejected the request (for example clipboard permissions).
    Rejected,
}
impl fmt::Display for DomError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "host operation: {self:?}")
    }
}
impl std::error::Error for DomError {}

/// The position to which a node is scrolled.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScrollBlock {
    /// Nearest visible edge, avoiding unnecessary motion.
    #[default]
    Nearest,
    /// Block-start edge.
    Start,
    /// Center of the visible area.
    Center,
}

/// Which side of a popup sits at a point's x.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Edge {
    /// The popup's left edge.
    Left,
    /// The popup's right edge.
    Right,
}

/// A viewport point a popup opens at, in CSS pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MenuPoint {
    /// Horizontal position.
    pub x: f64,
    /// Vertical position.
    pub y: f64,
    /// Where the popup's bottom goes if it would cross the viewport's, e.g. a
    /// trigger's top.
    pub flip_y: Option<f64>,
    /// Which edge sits at `x`; the inline start when `None`.
    pub edge: Option<Edge>,
}

/// An asynchronous host bridge; errors are acknowledged by the host.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct DomBridge;

impl DomBridge {
    async fn send(operation: &str, data: &str, option: &str) -> Result<(), DomError> {
        let eval = document::eval(include_str!("platform.js"));
        eval.send((operation, data, option))
            .map_err(|_| DomError::Unsupported)?;
        match eval
            .join::<String>()
            .await
            .map_err(|_| DomError::Unsupported)?
            .as_str()
        {
            "ok" => Ok(()),
            "missing" => Err(DomError::MissingTarget),
            "unsupported" => Err(DomError::Unsupported),
            _ => Err(DomError::Rejected),
        }
    }
    /// Open a dialog modally after mount. Fall back to an inline open dialog.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn show_modal(self, id: &Id) -> Result<(), DomError> {
        Self::send("modal", id.as_str(), "").await
    }
    /// Close a dialog. Fall back to removing its open state in the virtual DOM.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn close_dialog(self, id: &Id) -> Result<(), DomError> {
        Self::send("close", id.as_str(), "").await
    }
    /// Change popover visibility. Fall back to an in-flow panel.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn toggle_popover(self, id: &Id, open: bool) -> Result<(), DomError> {
        Self::send("popover", id.as_str(), if open { "open" } else { "close" }).await
    }
    /// Focus a node. Prefer `MountedData::set_focus` when a mount handle exists.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn focus(self, id: &Id) -> Result<(), DomError> {
        Self::send("focus", id.as_str(), "").await
    }
    /// Scroll a node into view; unsupported hosts leave the viewport unchanged.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn scroll_into_view(self, id: &Id, block: ScrollBlock) -> Result<(), DomError> {
        Self::send(
            "scroll",
            id.as_str(),
            match block {
                ScrollBlock::Nearest => "nearest",
                ScrollBlock::Start => "start",
                ScrollBlock::Center => "center",
            },
        )
        .await
    }
    /// Whether a scroll container is at its inline start and end, read from the host the
    /// way upstream's `carousel.ts` does, RTL included.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn scroll_edges(self, id: &Id) -> Result<(bool, bool), DomError> {
        let eval = document::eval(include_str!("platform.js"));
        eval.send(("edges", id.as_str(), ""))
            .map_err(|_| DomError::Unsupported)?;
        let answer = eval
            .join::<String>()
            .await
            .map_err(|_| DomError::Unsupported)?;
        match answer.split_once(' ') {
            Some((start, end)) => Ok((start == "true", end == "true")),
            None if answer == "missing" => Err(DomError::MissingTarget),
            None => Err(DomError::Unsupported),
        }
    }
    /// Scroll a container by most of its visible width, forwards in the reading direction
    /// or back.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn page(self, id: &Id, forward: bool) -> Result<(), DomError> {
        Self::send("page", id.as_str(), if forward { "next" } else { "prev" }).await
    }
    /// Place a popup list under its invoker, or above it when there is no room below,
    /// capped to the space available. Positioning needs layout, so it is the host's.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn place_below(self, list: &Id, invoker: &Id) -> Result<(), DomError> {
        Self::send("place", list.as_str(), invoker.as_str()).await
    }
    /// Place a fixed popup at a viewport point, as upstream's `menu.ts` does: its start
    /// edge at `x` (or `edge`), clamped inside the viewport, and moved above `flip_y`
    /// when it would cross the bottom.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn place_at(self, id: &Id, point: MenuPoint) -> Result<(), DomError> {
        let edge = match point.edge {
            Some(Edge::Left) => "left",
            Some(Edge::Right) => "right",
            None => "",
        };
        let flip = point.flip_y.map(|f| f.to_string()).unwrap_or_default();
        Self::send(
            "point",
            id.as_str(),
            &format!("{},{},{flip},{edge}", point.x, point.y),
        )
        .await
    }
    /// The point a menu opens at under a trigger: its start edge, 4px below, flipping to
    /// 4px above.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn point_below(self, trigger: &Id) -> Result<MenuPoint, DomError> {
        let eval = document::eval(include_str!("platform.js"));
        eval.send(("below", trigger.as_str(), ""))
            .map_err(|_| DomError::Unsupported)?;
        let answer = eval
            .join::<String>()
            .await
            .map_err(|_| DomError::Unsupported)?;
        let parts: Vec<&str> = answer.split(',').collect();
        match parts.as_slice() {
            [x, y, flip, edge] => Ok(MenuPoint {
                x: x.parse().map_err(|_| DomError::Rejected)?,
                y: y.parse().map_err(|_| DomError::Rejected)?,
                flip_y: flip.parse().ok(),
                edge: Some(if *edge == "right" {
                    Edge::Right
                } else {
                    Edge::Left
                }),
            }),
            _ if answer == "missing" => Err(DomError::MissingTarget),
            _ => Err(DomError::Unsupported),
        }
    }
    /// Route a pointer's events to an element for the rest of a drag.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn capture_pointer(self, id: &Id, pointer: i32) -> Result<(), DomError> {
        Self::send("capture", id.as_str(), &pointer.to_string()).await
    }
    /// Show a checkbox as mixed, which only a DOM property can express. Without a host
    /// the checkbox renders unchecked.
    /// # Errors
    /// Returns a host error when the target or API is unavailable.
    pub async fn set_indeterminate(self, id: &Id, mixed: bool) -> Result<(), DomError> {
        Self::send(
            "indeterminate",
            id.as_str(),
            if mixed { "on" } else { "off" },
        )
        .await
    }
    /// Copy text through a data channel. On failure, retain selectable source text.
    /// # Errors
    /// Returns `Unsupported` or `Rejected` when clipboard access is unavailable.
    pub async fn copy_text(self, text: &str) -> Result<(), DomError> {
        Self::send("copy", text, "").await
    }
    /// Wait `ms` milliseconds on the host's clock, for timed behaviour such as toast
    /// expiry. Hosts without a script runtime return `Unsupported` at once, and callers
    /// keep their untimed fallback (a toast then stays until dismissed).
    /// # Errors
    /// Returns `Unsupported` when the host cannot run timers.
    pub async fn delay(self, ms: u32) -> Result<(), DomError> {
        let eval = document::eval(
            "const ms = await dioxus.recv(); await new Promise(r => setTimeout(r, ms)); return 'ok';",
        );
        eval.send(ms).map_err(|_| DomError::Unsupported)?;
        match eval.join::<String>().await {
            Ok(_) => Ok(()),
            Err(_) => Err(DomError::Unsupported),
        }
    }
    /// Reload a frame in place. Re-rendering the same `src` would not navigate.
    /// # Errors
    /// Returns a host error when the target is missing or not a frame.
    pub async fn reload_frame(self, id: &Id) -> Result<(), DomError> {
        Self::send("reload", id.as_str(), "").await
    }
    /// Whether a user preference is on, or `None` on hosts without media queries, where
    /// callers keep their light, standard-contrast default.
    pub async fn prefers(self, preference: MediaPreference) -> Option<bool> {
        let eval = document::eval(
            "const query = await dioxus.recv(); return typeof matchMedia === 'function' ? matchMedia(query).matches : null;",
        );
        eval.send(preference.query()).ok()?;
        eval.join::<Option<bool>>().await.ok().flatten()
    }
    /// Follow a user preference: the current value, then each change. Hosts without
    /// media queries end the stream at once.
    #[must_use]
    pub fn watch(self, preference: MediaPreference) -> PreferenceWatch {
        let eval = document::eval(
            "const query = await dioxus.recv(); if (typeof matchMedia !== 'function') return; const media = matchMedia(query); dioxus.send(media.matches); media.addEventListener('change', event => dioxus.send(event.matches)); await new Promise(() => {});",
        );
        let started = eval.send(preference.query()).is_ok();
        PreferenceWatch { eval, started }
    }
    /// Mirror a root's theme attributes onto `<html>`, or release them with `None`.
    /// `token` names the root and `generation` orders its updates, so the document side
    /// can drop one that arrives late and restore what the page had once no root owns a
    /// name. Hosts without a document keep the theme scoped to the root's own element.
    /// # Errors
    /// Returns `Unsupported` when the host cannot run script.
    pub(crate) async fn mirror_document_theme(
        self,
        token: u64,
        generation: u64,
        pairs: Option<&ThemePairs>,
    ) -> Result<(), DomError> {
        let eval = Self::send_document_theme(token, generation, pairs)?;
        match eval.join::<String>().await {
            Ok(_) => Ok(()),
            Err(_) => Err(DomError::Unsupported),
        }
    }
    /// Release a root's attributes on `<html>` without waiting: this runs as the root
    /// unmounts, when nothing is left to await it.
    // A method like the rest of the bridge, so every host call reads `DomBridge.…`.
    #[allow(clippy::unused_self)]
    pub(crate) fn release_document_theme(self, token: u64, generation: u64) {
        let _ = Self::send_document_theme(token, generation, None);
    }
    fn send_document_theme(
        token: u64,
        generation: u64,
        pairs: Option<&ThemePairs>,
    ) -> Result<document::Eval, DomError> {
        // Names and values arrive through the channel, never as script text.
        let eval = document::eval(include_str!("document-theme.js"));
        eval.send((token, generation, pairs.map(|pairs| pairs.to_vec())))
            .map_err(|_| DomError::Unsupported)?;
        Ok(eval)
    }
    /// Detect available host APIs after mounting; SSR and native return defaults.
    pub async fn capabilities(self) -> Capabilities {
        let eval = document::eval("return [typeof HTMLDialogElement !== 'undefined' && 'showModal' in HTMLDialogElement.prototype, 'showPopover' in HTMLElement.prototype, !!navigator.clipboard?.writeText];");
        let (modal, popover, clipboard) =
            eval.join::<(bool, bool, bool)>().await.unwrap_or_default();
        Capabilities {
            modal,
            popover,
            clipboard,
        }
    }
}

/// Read the web entrypoint's query string. Non-web hosts supply initial props.
/// This is input to the application, not capability-dependent component markup.
#[must_use]
pub fn location_query() -> Option<String> {
    #[cfg(all(feature = "web", target_arch = "wasm32"))]
    {
        web_sys::window().and_then(|window| window.location().search().ok())
    }
    #[cfg(not(all(feature = "web", target_arch = "wasm32")))]
    {
        None
    }
}

/// Something upstream's editor binding reported.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum EditorEvent {
    /// The document changed; its HTML.
    Change(String),
    /// The marks covering the whole selection.
    Marks(Vec<crate::models::rich_text::Mark>),
}

/// Upstream's editor binding attached to one `.fc-editor`. Events arrive in order.
/// Dropping the session, as unmounting the editor does when its task ends, calls the
/// binding's `destroy()`, so no listener outlives the component.
pub struct EditorSession(document::Eval);
impl EditorSession {
    /// The next event, or `None` once the host stops sending.
    pub async fn next(&mut self) -> Option<EditorEvent> {
        let (kind, html, marks) = self
            .0
            .recv::<(String, String, Vec<crate::models::rich_text::Mark>)>()
            .await
            .ok()?;
        match kind.as_str() {
            "change" => Some(EditorEvent::Change(html)),
            _ => Some(EditorEvent::Marks(marks)),
        }
    }
    /// Replace the document. The HTML must come from the application's own state,
    /// never from untrusted input: like upstream's `value` setter, this is `innerHTML`.
    pub fn set_value(&self, html: &str) {
        let _ = self.0.send(("set", html));
    }
}
impl Drop for EditorSession {
    fn drop(&mut self) {
        let _ = self.0.send(("destroy", ""));
    }
}

impl DomBridge {
    /// Attach upstream's editor binding (`editor.ts`, shipped unchanged as JavaScript)
    /// to the `.fc-editor` with this id.
    /// # Errors
    /// `Unsupported` where the host cannot load modules (server rendering, native), and
    /// `MissingTarget` when no element has the id. The editor then stays plain,
    /// editable text with an inert toolbar, which upstream calls degraded but not broken.
    pub async fn bind_editor(self, id: &Id) -> Result<EditorSession, DomError> {
        let mut eval = document::eval(
            r#"const [id, base] = await dioxus.recv();
const root = document.getElementById(id);
if (!root) { dioxus.send(["missing", "", []]); return; }
const { FutureCssEditor } = await import(`${base}/editor.js`);
const editor = new FutureCssEditor({
  root,
  onChange: html => dioxus.send(["change", html, []]),
  onSelectionChange: active => dioxus.send(["marks", "", Object.keys(active).filter(mark => active[mark])]),
});
dioxus.send(["ready", "", []]);
while (true) {
  const [op, html] = await dioxus.recv();
  if (op === "destroy") { editor.destroy(); return; }
  editor.value = html;
}"#,
        );
        eval.send((id.as_str(), super::assets::BINDINGS_JS.to_string()))
            .map_err(|_| DomError::Unsupported)?;
        match eval
            .recv::<(String, String, Vec<crate::models::rich_text::Mark>)>()
            .await
        {
            Ok((kind, _, _)) if kind == "ready" => Ok(EditorSession(eval)),
            Ok(_) => Err(DomError::MissingTarget),
            Err(_) => Err(DomError::Unsupported),
        }
    }
}