1use std::{
2 fmt::{Display, Formatter},
3 rc::Rc,
4};
5
6use leptos::{
7 ev::{Event, KeyboardEvent, MouseEvent},
8 html::{AnyElement, Input},
9 *,
10};
11use radix_leptos_compose_refs::use_composed_refs;
12use radix_leptos_presence::Presence;
13use radix_leptos_primitive::{compose_callbacks, Primitive};
14use radix_leptos_use_controllable_state::{use_controllable_state, UseControllableStateParams};
15use radix_leptos_use_previous::use_previous;
16use radix_leptos_use_size::use_size;
17use web_sys::wasm_bindgen::{closure::Closure, JsCast};
18
19#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
20pub enum CheckedState {
21 False,
22 True,
23 Indeterminate,
24}
25
26impl Display for CheckedState {
27 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
28 write!(
29 f,
30 "{}",
31 match self {
32 CheckedState::False => "false",
33 CheckedState::True => "true",
34 CheckedState::Indeterminate => "indeterminate",
35 }
36 )
37 }
38}
39
40impl IntoAttribute for CheckedState {
41 fn into_attribute(self) -> Attribute {
42 Attribute::String(self.to_string().into())
43 }
44
45 fn into_attribute_boxed(self: Box<Self>) -> Attribute {
46 self.into_attribute()
47 }
48}
49
50#[derive(Clone, Debug)]
51struct CheckboxContextValue {
52 state: Signal<CheckedState>,
53 disabled: Signal<bool>,
54}
55
56#[component]
57pub fn Checkbox(
58 #[prop(into, optional)] name: MaybeProp<String>,
59 #[prop(into, optional)] checked: MaybeProp<CheckedState>,
60 #[prop(into, optional)] default_checked: MaybeProp<CheckedState>,
61 #[prop(into, optional)] on_checked_change: Option<Callback<CheckedState>>,
62 #[prop(into, optional)] required: MaybeProp<bool>,
63 #[prop(into, optional)] disabled: MaybeProp<bool>,
64 #[prop(into, optional)] value: MaybeProp<String>,
65 #[prop(into, optional)] on_keydown: Option<Callback<KeyboardEvent>>,
66 #[prop(into, optional)] on_click: Option<Callback<MouseEvent>>,
67 #[prop(into, optional)] as_child: MaybeProp<bool>,
68 #[prop(optional)] node_ref: NodeRef<AnyElement>,
69 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
70 children: ChildrenFn,
71) -> impl IntoView {
72 let name = Signal::derive(move || name.get());
73 let required = Signal::derive(move || required.get().unwrap_or(false));
74 let disabled = Signal::derive(move || disabled.get().unwrap_or(false));
75 let value = Signal::derive(move || value.get().unwrap_or("on".into()));
76
77 let button_ref = NodeRef::new();
78 let composed_refs = use_composed_refs(vec![node_ref, button_ref]);
79
80 let is_form_control = Signal::derive(move || {
81 button_ref
82 .get()
83 .and_then(|button| button.closest("form").ok())
84 .flatten()
85 .is_some()
86 });
87 let (checked, set_checked) = use_controllable_state(UseControllableStateParams {
88 prop: checked,
89 on_change: on_checked_change.map(|on_checked_change| {
90 Callback::new(move |value| {
91 if let Some(value) = value {
92 on_checked_change.call(value);
93 }
94 })
95 }),
96 default_prop: default_checked,
97 });
98 let checked = Signal::derive(move || checked.get().unwrap_or(CheckedState::False));
99
100 let initial_checked_state = RwSignal::new(checked.get_untracked());
101 let handle_reset: Rc<Closure<dyn Fn(Event)>> = Rc::new(Closure::new(move |_| {
102 set_checked.call(Some(initial_checked_state.get_untracked()));
103 }));
104
105 Effect::new({
106 let handle_reset = handle_reset.clone();
107
108 move |_| {
109 if let Some(form) = button_ref
110 .get()
111 .and_then(|button| button.closest("form").ok())
112 .flatten()
113 {
114 form.add_event_listener_with_callback(
115 "reset",
116 (*handle_reset).as_ref().unchecked_ref(),
117 )
118 .expect("Reset event listener should be added.");
119 }
120 }
121 });
122
123 on_cleanup(move || {
124 if let Some(form) = button_ref
125 .get()
126 .and_then(|button| button.closest("form").ok())
127 .flatten()
128 {
129 form.remove_event_listener_with_callback(
130 "reset",
131 (*handle_reset).as_ref().unchecked_ref(),
132 )
133 .expect("Reset event listener should be removed.");
134 }
135 });
136
137 let context_value = CheckboxContextValue {
138 state: checked,
139 disabled,
140 };
141
142 let mut attrs = attrs.clone();
143 attrs.extend([
144 ("type", "button".into_attribute()),
145 ("role", "checkbox".into_attribute()),
146 ("aria-checked", checked.into_attribute()),
147 (
148 "aria-required",
149 (move || match required.get() {
150 true => "true",
151 false => "false",
152 })
153 .into_attribute(),
154 ),
155 (
156 "data-state",
157 (move || get_state(checked.get())).into_attribute(),
158 ),
159 (
160 "data-disabled",
161 (move || disabled.get().then_some("")).into_attribute(),
162 ),
163 (
164 "disabled",
165 (move || disabled.get().then_some("")).into_attribute(),
166 ),
167 ("value", value.into_attribute()),
168 ]);
169
170 view! {
171 <Provider value=context_value>
172 <Primitive
173 element=html::button
174 as_child=as_child
175 node_ref=composed_refs
176 attrs=attrs
177 on:keydown=compose_callbacks(on_keydown, Some(Callback::new(move |event: KeyboardEvent| {
178 if event.key() == "Enter" {
180 event.prevent_default();
181 }
182 })), None)
183 on:click=compose_callbacks(on_click, Some(Callback::new(move |event: MouseEvent| {
184 set_checked.call(Some(match checked.get() {
185 CheckedState::False => CheckedState::True,
186 CheckedState::True => CheckedState::False,
187 CheckedState::Indeterminate => CheckedState::True
188 }));
189
190 if is_form_control.get() {
191 event.stop_propagation();
195 }
196 })), None)
197 >
198 {children()}
199 </Primitive>
200 <Show when=move || is_form_control.get()>
201 <BubbleInput
202 attr:name=name
203 control_ref=button_ref
204 bubbles=Signal::derive(|| true)
205 value=value
206 checked=checked
207 required=required
208 disabled=disabled
209 />
210 </Show>
211 </Provider>
212 }
213}
214
215#[component]
216pub fn CheckboxIndicator(
217 #[prop(into, optional)]
219 force_mount: MaybeProp<bool>,
220 #[prop(into, optional)] as_child: MaybeProp<bool>,
221 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
222 #[prop(optional)] children: Option<ChildrenFn>,
223) -> impl IntoView {
224 let force_mount = Signal::derive(move || force_mount.get().unwrap_or(false));
225
226 let context = expect_context::<CheckboxContextValue>();
227
228 let present = Signal::derive(move || {
229 force_mount.get()
230 || context.state.get() == CheckedState::Indeterminate
231 || context.state.get() == CheckedState::True
232 });
233
234 let mut attrs = attrs.clone();
235 attrs.extend([
236 (
237 "data-state",
238 (move || get_state(context.state.get())).into_attribute(),
239 ),
240 (
241 "data-disabled",
242 (move || context.disabled.get().then_some("")).into_attribute(),
243 ),
244 ("style", "pointer-events: none;".into_attribute()),
245 ]);
246
247 let attrs = StoredValue::new(attrs);
248 let children = StoredValue::new(children);
249
250 view! {
251 <Presence present=present>
252 <Primitive
253 element=html::span
254 as_child=as_child
255 attrs=attrs.get_value()
256 >
257 {children.with_value(|children| children.as_ref().map(|children| children()))}
258 </Primitive>
259 </Presence>
260 }
261}
262
263#[component]
264fn BubbleInput(
265 #[prop(into)] control_ref: NodeRef<AnyElement>,
266 #[prop(into)] checked: Signal<CheckedState>,
267 #[prop(into)] bubbles: Signal<bool>,
268 #[prop(into)] required: Signal<bool>,
269 #[prop(into)] disabled: Signal<bool>,
270 #[prop(into)] value: Signal<String>,
271 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
272) -> impl IntoView {
273 let node_ref: NodeRef<Input> = NodeRef::new();
274 let prev_checked = use_previous(checked);
275 let control_size = use_size(control_ref);
276
277 Effect::new(move |_| {
279 if let Some(input) = node_ref.get() {
280 if prev_checked.get() != checked.get() {
281 let init = web_sys::EventInit::new();
282 init.set_bubbles(bubbles.get());
283
284 let event = web_sys::Event::new_with_event_init_dict("click", &init)
285 .expect("Click event should be instantiated.");
286
287 input.set_indeterminate(is_indeterminiate(checked.get()));
288 input.set_checked(match checked.get() {
289 CheckedState::False => false,
290 CheckedState::True => true,
291 CheckedState::Indeterminate => false,
292 });
293
294 input
295 .dispatch_event(&event)
296 .expect("Click event should be dispatched.");
297 }
298 }
299 });
300
301 view! {
302 <input
303 node_ref=node_ref
304 type="checkbox"
305 aria-hidden="true"
306 checked=move || (match checked.get() {
307 CheckedState::False => false,
308 CheckedState::True => true,
309 CheckedState::Indeterminate => false,
310 }).then_some("")
311 required=move || required.get().then_some("")
312 disabled=move || disabled.get().then_some("")
313 value=value
314 tab-index="-1"
315 style:transform="translateX(-100%)"
319 style:width=move || control_size.get().map(|size| format!("{}px", size.width))
320 style:height=move || control_size.get().map(|size| format!("{}px", size.height))
321 style:position="absolute"
322 style:pointer-events="none"
323 style:opacity="0"
324 style:margin="0px"
325 {..attrs}
326 />
327 }
328}
329
330fn is_indeterminiate(checked: CheckedState) -> bool {
331 checked == CheckedState::Indeterminate
332}
333
334fn get_state(checked: CheckedState) -> String {
335 (match checked {
336 CheckedState::True => "checked",
337 CheckedState::False => "unchecked",
338 CheckedState::Indeterminate => "indeterminate",
339 })
340 .into()
341}