Skip to content

Commit bf5800c

Browse files
authored
Merge pull request #23 from stlab/worktree-spectrum-supprt
feat(begin): integrate Adobe Spectrum Web Components
2 parents 978c52c + 94a70a1 commit bf5800c

14 files changed

Lines changed: 3995 additions & 37 deletions

File tree

.cargo/config.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[alias]
2+
xtask = "run --package xtask --"

.vscode/tasks.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@
7878
},
7979
"isBackground": true
8080
},
81+
{
82+
"label": "begin: fetch assets",
83+
"type": "shell",
84+
"command": "cargo",
85+
"args": ["xtask", "fetch-assets"],
86+
"options": {
87+
"cwd": "${workspaceFolder}"
88+
},
89+
"problemMatcher": ["$rustc"],
90+
"presentation": {
91+
"reveal": "always",
92+
"panel": "dedicated"
93+
}
94+
},
8195
// ============ Build Tasks ============
8296
{
8397
"label": "cargo build",

Cargo.lock

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ members = [
2626
"cel-rs-macros",
2727
"property-model",
2828
"begin",
29+
"xtask",
2930
]
30-
3131
[lib]
3232
name = "cel_rs"
3333
path = "src/lib.rs"

begin/assets/swc.js

Lines changed: 2794 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

begin/assets/versions.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[d3]
2+
version = "7.9.0"
3+
url = "https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"
4+
file = "d3.v7.min.js"
5+
6+
[spectrum-web-components]
7+
# 0.45.4 does not exist on npm; 0.45.0 is the latest 0.45.x release.
8+
# URL uses esm.sh bundled endpoint for a self-contained single-file ESM bundle.
9+
version = "0.45.0"
10+
url = "https://esm.sh/@spectrum-web-components/bundle@0.45.0/es2022/elements.bundle.mjs"
11+
file = "swc.js"

begin/src/app.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use property_model::{Method, Sheet};
66
use crate::bridge::{Labels, to_graph_data};
77
use crate::graph_view::GraphView;
88
use crate::inspector::Inspector;
9+
use crate::spectrum::SpTheme;
910

1011
/// Builds the `a × b = c` demo sheet with three bidirectional methods.
1112
///
@@ -55,7 +56,8 @@ pub fn make_demo_sheet() -> (Sheet, Labels) {
5556
(sheet, labels)
5657
}
5758

58-
/// Root component: two-panel layout with the D3 graph on the left and the Inspector on the right.
59+
/// Root component: Spectrum theme wrapper, two-panel layout with the D3 graph on the
60+
/// left and the Inspector on the right.
5961
#[component]
6062
pub fn App() -> Element {
6163
let (initial_sheet, initial_labels) = make_demo_sheet();
@@ -68,11 +70,16 @@ pub fn App() -> Element {
6870
document::Link { rel: "stylesheet", href: asset!("/assets/graph.css") }
6971
document::Script { src: asset!("/assets/d3.v7.min.js") }
7072
document::Script { src: asset!("/assets/graph.js") }
73+
document::Script { r#type: "module", src: asset!("/assets/swc.js") }
7174

72-
div {
73-
style: "position: fixed; inset: 0; display: flex; overflow: hidden;",
74-
GraphView { data: graph_data }
75-
Inspector { sheet, labels }
75+
SpTheme {
76+
color: "light".to_string(),
77+
scale: "medium".to_string(),
78+
div {
79+
style: "position: fixed; inset: 0; display: flex; overflow: hidden;",
80+
GraphView { data: graph_data }
81+
Inspector { sheet, labels }
82+
}
7683
}
7784
}
7885
}

begin/src/inspector.rs

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,24 @@ use dioxus::prelude::*;
44
use property_model::{CellId, Sheet};
55

66
use crate::bridge::Labels;
7+
use crate::spectrum::{SpDivider, SpFieldLabel, SpHeading, SpTextfield};
78

89
/// Sidebar panel showing all cells with labels, current values, and text inputs for writing.
910
///
1011
/// Editing an input field immediately writes the parsed value to the sheet and propagates
11-
/// constraints. If propagation fails (for example, division by zero), the input field shows
12-
/// a red border until the user blurs. The input is not reset while the field is focused;
13-
/// it syncs back to the computed value on blur, keeping non-edited cells up to date.
12+
/// constraints. If parsing or propagation fails (for example, non-numeric input or division
13+
/// by zero), `SpTextfield` renders in its invalid state until the user blurs. The input is
14+
/// not reset while the field is focused; it syncs back to the computed value on blur,
15+
/// keeping non-edited cells up to date.
1416
#[component]
1517
pub fn Inspector(sheet: Signal<Sheet>, labels: Signal<Labels>) -> Element {
1618
let ids: Vec<CellId> = labels.read().cells.keys().copied().collect();
1719

1820
rsx! {
1921
div {
20-
style: "width: 260px; min-width: 260px; height: 100%; overflow-y: auto; border-left: 1px solid #ddd; padding: 12px; box-sizing: border-box; font-family: monospace; font-size: 13px;",
21-
h3 { style: "margin: 0 0 12px 0; font-size: 14px;", "Cells" }
22+
style: "width: 260px; min-width: 260px; height: 100%; overflow-y: auto; padding: 12px; box-sizing: border-box;",
23+
SpHeading { "Cells" }
24+
SpDivider {}
2225
for id in ids {
2326
CellRow { key: "{id:?}", id, sheet, labels }
2427
}
@@ -59,41 +62,55 @@ fn CellRow(id: CellId, sheet: Signal<Sheet>, labels: Signal<Labels>) -> Element
5962
}
6063
});
6164

65+
let field_id = format!("cell-{id:?}");
66+
6267
rsx! {
6368
div {
64-
style: "margin-bottom: 10px;",
65-
div { style: "font-weight: bold; margin-bottom: 2px;", "{label}" }
66-
div { style: "color: #888; margin-bottom: 4px; font-size: 11px;", "{value}" }
67-
input {
68-
r#type: "text",
69-
value: "{input}",
70-
style: if *has_error.read() {
71-
"width: 100%; box-sizing: border-box; font-family: monospace; font-size: 12px; border-color: #c00;"
72-
} else {
73-
"width: 100%; box-sizing: border-box; font-family: monospace; font-size: 12px;"
69+
style: "margin-bottom: 8px;",
70+
SpFieldLabel { for_: field_id.clone(), "{label}" }
71+
SpTextfield {
72+
id: field_id,
73+
value: input.read().clone(),
74+
invalid: *has_error.read(),
75+
// Dioxus's event serializer only reads event.target.value for
76+
// HTMLInputElement — custom elements (sp-textfield) always give "".
77+
// Use dioxus.send() in JS and eval.recv() to read the live value.
78+
oninput: move |_: FormEvent| {
79+
spawn(async move {
80+
let mut eval = document::eval(&format!(
81+
r#"dioxus.send(document.getElementById("cell-{id:?}").value)"#
82+
));
83+
let Ok(val) = eval.recv::<String>().await else { return; };
84+
// Discard the result if the user blurred while the round-trip was
85+
// in flight; blur already cleared the error and use_effect will
86+
// restore the last valid computed value.
87+
if !*is_focused.read() {
88+
return;
89+
}
90+
input.set(val.clone());
91+
let mut sheet_w = sheet.write();
92+
let labels_r = labels.read();
93+
if let Some(meta) = labels_r.cells.get(&id) {
94+
if (meta.write_str)(&mut sheet_w, &val).is_ok() {
95+
let result = if sheet_w.is_source(id) {
96+
sheet_w.propagate_without_replan()
97+
} else {
98+
sheet_w.propagate()
99+
};
100+
has_error.set(result.is_err());
101+
} else {
102+
has_error.set(true);
103+
}
104+
}
105+
});
74106
},
75107
onfocus: move |_| is_focused.set(true),
76108
onblur: move |_| {
77109
is_focused.set(false);
78110
has_error.set(false);
79111
},
80-
oninput: move |e| {
81-
let s = e.value();
82-
input.set(s.clone());
83-
let mut sheet_w = sheet.write();
84-
let labels_r = labels.read();
85-
if let Some(meta) = labels_r.cells.get(&id)
86-
&& (meta.write_str)(&mut sheet_w, &s).is_ok()
87-
{
88-
let result = if sheet_w.is_source(id) {
89-
sheet_w.propagate_without_replan()
90-
} else {
91-
sheet_w.propagate()
92-
};
93-
has_error.set(result.is_err());
94-
}
95-
},
96112
}
97113
}
114+
SpDivider {}
98115
}
99116
}

begin/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod app;
33
mod bridge;
44
mod graph_view;
55
mod inspector;
6+
mod spectrum;
67

78
fn main() {
89
dioxus::launch(app::App);

begin/src/spectrum.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//! Dioxus element bindings and component wrappers for Spectrum Web Components.
2+
//!
3+
//! Import with `use crate::spectrum::*;` to bring component wrappers into scope.
4+
//! Callers only need the `SpXxx` component functions.
5+
6+
#![allow(non_snake_case)]
7+
8+
use dioxus::prelude::*;
9+
10+
// ─── Component wrappers ─────────────────────────────────────────────────────
11+
// PascalCase functions are Dioxus components; RSX resolves them via function
12+
// call, not as element bindings. Each wraps one SWC custom element.
13+
//
14+
// Hyphenated identifiers in RSX (e.g. `sp-theme`) are parsed as custom-element
15+
// string literals by the RSX macro (ElementName::Custom), so no element module
16+
// declaration is required — the tag name is emitted verbatim.
17+
18+
/// Provides Spectrum token context for all descendant SWC components.
19+
///
20+
/// Must be the root ancestor of any `SpXxx` component. Maps to `<sp-theme>`.
21+
#[component]
22+
pub fn SpTheme(color: String, scale: String, children: Element) -> Element {
23+
rsx! {
24+
sp-theme {
25+
"color": "{color}",
26+
"scale": "{scale}",
27+
{children}
28+
}
29+
}
30+
}
31+
32+
/// Single-line text input.
33+
///
34+
/// Maps to `<sp-textfield>`. Fires standard DOM `input`, `focus`, and `blur`
35+
/// events. Setting `invalid` to `true` renders the SWC error state (red ring
36+
/// and `aria-invalid`).
37+
#[component]
38+
pub fn SpTextfield(
39+
id: String,
40+
value: String,
41+
invalid: bool,
42+
oninput: EventHandler<FormEvent>,
43+
onfocus: EventHandler<FocusEvent>,
44+
onblur: EventHandler<FocusEvent>,
45+
) -> Element {
46+
rsx! {
47+
sp-textfield {
48+
"id": "{id}",
49+
"value": "{value}",
50+
// Boolean attribute: omit entirely when false; presence = invalid.
51+
"invalid": if invalid { "true" },
52+
oninput: move |e| oninput.call(e),
53+
onfocus: move |e| onfocus.call(e),
54+
onblur: move |e| onblur.call(e),
55+
}
56+
}
57+
}
58+
59+
/// Label associated with a form control.
60+
///
61+
/// Maps to `<sp-field-label>`. The `for_` prop sets the `for` HTML attribute
62+
/// linking the label to an input by id.
63+
#[component]
64+
pub fn SpFieldLabel(for_: String, children: Element) -> Element {
65+
rsx! {
66+
sp-field-label {
67+
"for": "{for_}",
68+
{children}
69+
}
70+
}
71+
}
72+
73+
/// Horizontal visual separator.
74+
///
75+
/// Maps to `<sp-divider>` with `size="s"` (small).
76+
#[component]
77+
pub fn SpDivider() -> Element {
78+
rsx! {
79+
sp-divider {
80+
"size": "s",
81+
}
82+
}
83+
}
84+
85+
/// Section heading.
86+
///
87+
/// Maps to `<sp-heading>`.
88+
#[component]
89+
pub fn SpHeading(children: Element) -> Element {
90+
rsx! {
91+
sp-heading {
92+
{children}
93+
}
94+
}
95+
}

0 commit comments

Comments
 (0)