patternfly_yew/components/
clipboard.rs

1//! Copy clipboard
2use crate::icon::*;
3use crate::prelude::TextInput;
4use crate::prelude::*;
5use gloo_timers::callback::Timeout;
6use wasm_bindgen::prelude::*;
7use web_sys::{Element, HtmlInputElement};
8use yew::prelude::*;
9
10/// Properties for [``Clipboard]
11#[derive(Clone, PartialEq, Properties)]
12pub struct ClipboardProperties {
13    #[prop_or_default]
14    pub value: String,
15    #[prop_or_default]
16    pub readonly: bool,
17    #[prop_or_default]
18    pub code: bool,
19    #[prop_or_default]
20    pub variant: ClipboardVariant,
21    #[prop_or_default]
22    pub name: String,
23    #[prop_or_default]
24    pub id: String,
25}
26
27#[derive(Clone, Default, PartialEq, Eq, Debug)]
28pub enum ClipboardVariant {
29    // default
30    #[default]
31    Default,
32    // inline
33    Inline,
34    // expandable
35    Expandable,
36    // expandable and initially expanded
37    Expanded,
38}
39
40impl ClipboardVariant {
41    pub fn is_expandable(&self) -> bool {
42        matches!(self, Self::Expandable | Self::Expanded)
43    }
44
45    pub fn is_inline(&self) -> bool {
46        matches!(self, Self::Inline)
47    }
48}
49
50#[doc(hidden)]
51#[derive(Clone, Debug)]
52pub enum Msg {
53    Copy,
54    Copied,
55    Failed(&'static str),
56    Reset,
57    ToggleExpand,
58    /// Sync the content from the
59    Sync,
60}
61
62const DEFAULT_MESSAGE: &str = "Copy to clipboard";
63const FAILED_MESSAGE: &str = "Failed to copy";
64const OK_MESSAGE: &str = "Copied!";
65
66/// Clipboard copy component
67///
68/// > The **clipboard copy** component allows users to quickly and easily copy content to their clipboard.
69///
70/// See: <https://www.patternfly.org/components/clipboard-copy>
71///
72/// ## Properties
73///
74/// Defined by [`ClipboardProperties`].
75pub struct Clipboard {
76    message: &'static str,
77    task: Option<Timeout>,
78    expanded: bool,
79    // the value, when overridden by the user
80    value: Option<String>,
81    text_ref: NodeRef,
82    details_ref: NodeRef,
83}
84
85impl Component for Clipboard {
86    type Message = Msg;
87    type Properties = ClipboardProperties;
88
89    fn create(ctx: &Context<Self>) -> Self {
90        let expanded = matches!(ctx.props().variant, ClipboardVariant::Expanded);
91
92        Self {
93            message: DEFAULT_MESSAGE,
94            task: None,
95            expanded,
96            value: None,
97            text_ref: NodeRef::default(),
98            details_ref: NodeRef::default(),
99        }
100    }
101
102    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
103        match msg {
104            Msg::Copy => {
105                self.do_copy(ctx);
106            }
107            Msg::Copied => {
108                self.trigger_message(ctx, OK_MESSAGE);
109            }
110            Msg::Failed(msg) => {
111                self.trigger_message(ctx, msg);
112            }
113            Msg::Reset => {
114                self.message = DEFAULT_MESSAGE;
115                self.task.take();
116            }
117            Msg::ToggleExpand => {
118                self.expanded = !self.expanded;
119            }
120            Msg::Sync => {
121                self.sync_from_edit(ctx);
122                return false;
123            }
124        }
125        true
126    }
127
128    fn view(&self, ctx: &Context<Self>) -> Html {
129        let mut classes = Classes::from("pf-v5-c-clipboard-copy");
130
131        if self.expanded {
132            classes.push("pf-m-expanded");
133        }
134        if ctx.props().variant.is_inline() {
135            classes.push("pf-m-inline");
136        }
137
138        let value = self.value(ctx);
139
140        html! {
141            <div class={classes}>
142                { match ctx.props().variant {
143                    ClipboardVariant::Inline => {
144                        html!{
145                            <>
146                            if ctx.props().code {
147                                <code name={ctx.props().name.clone()} id={ctx.props().id.clone()} class="pf-v5-c-clipboard-copy__text pf-m-code">{value}</code>
148                            } else {
149                                <span name={ctx.props().name.clone()} id={ctx.props().id.clone()} class="pf-v5-c-clipboard-copy__text">{value}</span>
150                            }
151                            <span class="pf-v5-c-clipboard-copy__actions">
152                                <span class="pf-v5-c-clipboard-copy__actions-item">
153                                    <Tooltip text={self.message}>
154                                        <Button aria_label="Copy to clipboard" variant={ButtonVariant::Plain} icon={Icon::Copy} onclick={ctx.link().callback(|_|Msg::Copy)}/>
155                                    </Tooltip>
156                                </span>
157                            </span>
158                            </>
159                        }
160                    },
161                    _ => {
162                        html!{
163                            <>
164                            <div class="pf-v5-c-clipboard-copy__group">
165                                { self.expander(ctx) }
166                                <TextInput
167                                    r#ref={self.text_ref.clone()}
168                                    readonly={ctx.props().readonly | self.expanded}
169                                    value={value}
170                                    name={ctx.props().name.clone()}
171                                    id={ctx.props().id.clone()}
172                                    oninput={ctx.link().callback(|_|Msg::Sync)}
173                                />
174                                <Tooltip text={self.message}>
175                                    <Button aria_label="Copy to clipboard" variant={ButtonVariant::Control} icon={Icon::Copy} onclick={ctx.link().callback(|_|Msg::Copy)}/>
176                                </Tooltip>
177                            </div>
178                            { self.expanded(ctx) }
179                            </>
180                        }
181                    }
182                }}
183            </div>
184        }
185    }
186}
187
188impl Clipboard {
189    fn value(&self, ctx: &Context<Self>) -> String {
190        self.value
191            .clone()
192            .unwrap_or_else(|| ctx.props().value.clone())
193    }
194
195    fn trigger_message(&mut self, ctx: &Context<Self>, msg: &'static str) {
196        self.message = msg;
197        self.task = Some({
198            let link = ctx.link().clone();
199            Timeout::new(2_000, move || {
200                link.send_message(Msg::Reset);
201            })
202        });
203    }
204
205    fn do_copy(&self, ctx: &Context<Self>) {
206        let s = self.value(ctx);
207
208        let ok: Callback<()> = ctx.link().callback(|_| Msg::Copied);
209        let err: Callback<&'static str> = ctx.link().callback(Msg::Failed);
210
211        wasm_bindgen_futures::spawn_local(async move {
212            match copy_to_clipboard(s).await {
213                Ok(_) => ok.emit(()),
214                Err(_) => err.emit(FAILED_MESSAGE),
215            };
216        });
217    }
218
219    fn expander(&self, ctx: &Context<Self>) -> Html {
220        if !ctx.props().variant.is_expandable() {
221            return Default::default();
222        }
223
224        let onclick = ctx.link().callback(|_| Msg::ToggleExpand);
225
226        html! (
227            <Button
228                expanded={self.expanded}
229                variant={ButtonVariant::Control}
230                onclick={onclick}>
231                <div class="pf-v5-c-clipboard-copy__toggle-icon">
232                    { Icon::AngleRight }
233                </div>
234            </Button>
235        )
236    }
237
238    fn expanded(&self, ctx: &Context<Self>) -> Html {
239        if !self.expanded {
240            return Default::default();
241        }
242
243        let value = self.value(ctx);
244
245        html! {
246            <div
247                ref={self.details_ref.clone()}
248                class="pf-v5-c-clipboard-copy__expandable-content"
249                contenteditable={(!ctx.props().readonly).to_string()}
250                oninput={ctx.link().callback(|_|Msg::Sync)}
251            >
252
253                if ctx.props().code {
254                    <pre>{ value }</pre>
255                } else {
256                    { value }
257                }
258
259            </div>
260        }
261    }
262
263    /// Sync the value between internal, text field or details.
264    fn sync_from_edit(&mut self, ctx: &Context<Self>) {
265        if ctx.props().readonly || ctx.props().variant.is_inline() {
266            return;
267        }
268
269        let value = if self.expanded {
270            // from div to input
271            let ele: Option<Element> = self.details_ref.cast::<Element>();
272            ele.and_then(|ele| ele.text_content())
273                .unwrap_or_else(|| "".into())
274        } else {
275            // from input to div
276            let ele: Option<HtmlInputElement> = self.text_ref.cast::<HtmlInputElement>();
277            ele.map(|ele| ele.value()).unwrap_or_else(|| "".into())
278        };
279
280        log::debug!("New value: {}", value);
281
282        // sync back
283        match self.expanded {
284            true => {
285                if let Some(ele) = self.text_ref.cast::<HtmlInputElement>() {
286                    ele.set_value(&value);
287                }
288            }
289            false => {
290                if let Some(ele) = self.details_ref.cast::<Element>() {
291                    ele.set_text_content(Some(&value));
292                }
293            }
294        }
295
296        // sync to internal state
297
298        self.value = Some(value);
299    }
300}
301
302#[wasm_bindgen(inline_js=r#"
303export function copy_to_clipboard(value) {
304    try {
305        return window.navigator.clipboard.writeText(value);
306    } catch(e) {
307        console.log(e);
308        return Promise.reject(e)
309    }
310}
311"#)]
312#[rustfmt::skip] // required to keep the "async" keyword
313extern "C" {
314    #[wasm_bindgen(catch)]
315    async fn copy_to_clipboard(value: String) -> Result<(), JsValue>;
316}