1use crate::{
8 behavior::{
9 roving,
10 url::{frame_url, is_secure},
11 },
12 core::{
13 assets,
14 attrs::{component_attributes, named_state_attribute},
15 platform::DomBridge,
16 state::Orientation,
17 },
18 prelude::*,
19 theme::{use_direction, use_stylesheets},
20};
21use std::rc::Rc;
22const BEM: Bem = Bem::new("fc-webview");
23const VISUALLY_HIDDEN: Bem = Bem::new("fc-u-visually-hidden");
24
25#[non_exhaustive]
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub enum WebviewState {
29 #[default]
31 Idle,
32 Loading,
34 Loaded,
36 Error,
38}
39impl WebviewState {
40 const fn as_str(self) -> &'static str {
41 match self {
42 Self::Idle => "idle",
43 Self::Loading => "loading",
44 Self::Loaded => "loaded",
45 Self::Error => "error",
46 }
47 }
48}
49
50#[component]
54pub fn Webview(
55 title: AccessibleName,
57 src: String,
59 #[props(default)]
61 tabs: Vec<String>,
62 #[props(default)] selected_tab: usize,
63 #[props(default)] ontab: Option<EventHandler<usize>>,
64 #[props(default = "allow-scripts allow-forms allow-popups".to_owned())]
67 sandbox: String,
68 #[props(default)]
70 error: Option<String>,
71 #[props(default)] onnavigate: Option<EventHandler<String>>,
72 #[props(default)] onstate: Option<EventHandler<WebviewState>>,
73 #[props(default)] class: Option<String>,
74 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
75) -> Element {
76 let first = frame_url(&src);
79 let mut history = use_signal(|| first.clone().map(|url| vec![url]).unwrap_or_default());
80 let mut at = use_signal(|| 0_usize);
81 let mut state = use_signal(|| {
82 if first.is_ok() {
83 WebviewState::Loading
84 } else {
85 WebviewState::Error
86 }
87 });
88 let mut draft = use_signal(|| first.clone().unwrap_or_else(|_| src.clone()));
89 let mut seen = use_signal(|| src.clone());
90 let mut tab_nodes = use_signal(Vec::<(usize, Rc<MountedData>)>::new);
91 let frame_id = use_id();
92 let mut chosen = use_signal(|| selected_tab);
95 let mut seen_tab = use_signal(|| selected_tab);
96 if *seen_tab.peek() != selected_tab {
97 seen_tab.set(selected_tab);
98 chosen.set(selected_tab);
99 }
100 let mut reload = {
103 let frame_id = frame_id.clone();
104 move || {
105 let frame_id = frame_id.clone();
106 state.set(WebviewState::Loading);
107 if let Some(onstate) = onstate {
108 onstate.call(WebviewState::Loading);
109 }
110 spawn(async move {
111 let _ = DomBridge.reload_frame(&frame_id).await;
112 });
113 }
114 };
115 let mut choose_tab = move |index: usize| {
116 chosen.set(index);
117 if let Some(ontab) = ontab {
118 ontab.call(index);
119 }
120 };
121 let direction = use_direction();
122
123 let mut set_state = move |next: WebviewState| {
124 state.set(next);
125 if let Some(onstate) = onstate {
126 onstate.call(next);
127 }
128 };
129 let mut go = move |raw: String, record: bool| match frame_url(&raw) {
130 Ok(url) => {
131 if record {
132 let keep = if history.peek().is_empty() {
134 0
135 } else {
136 at() + 1
137 };
138 let mut list = history.write();
139 list.truncate(keep);
140 list.push(url.clone());
141 at.set(list.len() - 1);
142 }
143 draft.set(url.clone());
144 set_state(WebviewState::Loading);
145 if let Some(onnavigate) = onnavigate {
146 onnavigate.call(url);
147 }
148 }
149 Err(_) => set_state(WebviewState::Error),
151 };
152 use_effect(use_reactive!(|src| {
153 if *seen.peek() != src {
154 seen.set(src.clone());
155 go(src, true);
156 }
157 }));
158
159 let current = history.read().get(at()).cloned().unwrap_or_default();
160 let can_back = at() > 0;
161 let can_forward = at() + 1 < history.read().len();
162 let mut step = move |forward: bool| {
163 let target = if forward {
164 at() + 1
165 } else {
166 at().saturating_sub(1)
167 };
168 let url = history.peek().get(target).cloned();
169 if let Some(url) = url {
170 at.set(target);
171 go(url, false);
172 }
173 };
174
175 let mut attributes = component_attributes(
176 attributes,
177 ClassList::new(BEM.block()).extend(class.as_deref()),
178 &[],
179 );
180 attributes.push(named_state_attribute(state().as_str()));
181 let secure = is_secure(¤t);
182 let failed = state() == WebviewState::Error;
183 let tab_count = tabs.len();
184 let nav_class = BEM.element("nav");
185 let back_class = ClassList::new(&nav_class)
186 .with(BEM.element("nav--back"))
187 .to_string();
188
189 rsx! {
190 if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_WEBVIEW_CSS } }
191 div { ..attributes,
192 if !tabs.is_empty() {
193 div { class: BEM.element("tabs"), role: "tablist", aria_label: "Pages",
194 for (index, tab) in tabs.iter().enumerate() {
195 button { key: "{index}", r#type: "button", class: BEM.element("tab"), role: "tab",
196 aria_selected: (index == chosen()).to_string(),
197 tabindex: if index == chosen() { "0" } else { "-1" },
198 onmounted: move |event: MountedEvent| {
199 let mut nodes = tab_nodes.write();
200 nodes.retain(|(i, _)| *i != index);
201 nodes.push((index, event.data()));
202 },
203 onclick: move |_| choose_tab(index),
204 onkeydown: move |event: KeyboardEvent| {
205 let Some(step) = roving::intent(&event.key(), Orientation::Horizontal, direction) else { return };
206 let Some(next) = roving::target(tab_count, index, step, |_| true) else { return };
207 event.prevent_default();
208 choose_tab(next);
210 let node = tab_nodes.peek().iter().find(|(i, _)| *i == next).map(|(_, n)| n.clone());
211 if let Some(node) = node {
212 spawn(async move { let _ = node.set_focus(true).await; });
213 }
214 },
215 span { class: BEM.element("tab-label"), "{tab}" }
216 }
217 }
218 }
219 }
220 div { class: BEM.element("toolbar"),
221 Button { variant: ButtonVariant::Ghost, size: ButtonSize::Sm, icon_label: name!("Back"), class: back_class,
223 availability: if can_back { Availability::Enabled } else { Availability::Disabled },
224 onclick: move |_| step(false), "\u{2190}" }
225 Button { variant: ButtonVariant::Ghost, size: ButtonSize::Sm, icon_label: name!("Forward"), class: nav_class.clone(),
226 availability: if can_forward { Availability::Enabled } else { Availability::Disabled },
227 onclick: move |_| step(true), "\u{2192}" }
228 Button { variant: ButtonVariant::Ghost, size: ButtonSize::Sm, icon_label: name!("Reload"), class: nav_class,
229 availability: if current.is_empty() { Availability::Disabled } else { Availability::Enabled },
230 onclick: { let mut reload = reload.clone(); move |_| reload() }, "\u{21bb}" }
231 form { class: BEM.element("address"), role: "search",
233 onsubmit: move |event: FormEvent| {
234 event.prevent_default();
235 go(draft(), true);
236 },
237 match secure {
238 Some(true) => rsx! {
239 span { class: BEM.element("secure"),
240 span { aria_hidden: "true", "\u{1f512}" }
241 span { class: VISUALLY_HIDDEN.block(), "Secure" }
242 }
243 },
244 Some(false) => rsx! {
246 span { class: BEM.element("secure"), ..crate::spread![named_state_attribute("insecure")],
247 span { aria_hidden: "true", "\u{26a0}" }
248 span { "Not secure" }
249 }
250 },
251 None => rsx! {},
252 }
253 input { class: BEM.element("address-input"), r#type: "text", aria_label: "Address",
254 autocomplete: "off", spellcheck: "false", value: "{draft}",
255 oninput: move |event| draft.set(event.value()) }
256 }
257 }
258 iframe { id: "{frame_id}", class: BEM.element("frame"), title: "{title}",
261 src: if current.is_empty() { "about:blank".to_owned() } else { current.clone() }, "sandbox": "{sandbox}",
262 referrerpolicy: "strict-origin-when-cross-origin",
264 allow: "",
266 onload: move |_| if !current_failed(state) { set_state(WebviewState::Loaded) },
267 }
268 div { class: BEM.element("overlay"), role: "alert", hidden: !failed,
269 if failed {
270 p { class: BEM.element("error"), {error.clone().unwrap_or_else(|| "This page could not be loaded.".to_owned())} }
271 Button { variant: ButtonVariant::Secondary, size: ButtonSize::Sm,
272 onclick: move |_| {
273 let shown = history.peek().get(at()).cloned();
276 if let Some(url) = shown {
277 draft.set(url);
278 reload();
279 }
280 },
281 "Retry"
282 }
283 }
284 }
285 }
286 }
287}
288
289fn current_failed(state: Signal<WebviewState>) -> bool {
292 *state.peek() == WebviewState::Error
293}