Skip to main content

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-v6-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-v6-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-v6-c-clipboard-copy__text">{value}</span>
150                            }
151                            <span class="pf-v6-c-clipboard-copy__actions">
152                                <span class="pf-v6-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-v6-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 expanded={self.expanded} variant={ButtonVariant::Control} onclick={onclick}>
228                <div class="pf-v6-c-clipboard-copy__toggle-icon">{ Icon::AngleRight }</div>
229            </Button>
230        )
231    }
232
233    fn expanded(&self, ctx: &Context<Self>) -> Html {
234        if !self.expanded {
235            return Default::default();
236        }
237
238        let value = self.value(ctx);
239
240        html! {
241            <div
242                ref={self.details_ref.clone()}
243                class="pf-v6-c-clipboard-copy__expandable-content"
244                contenteditable={(!ctx.props().readonly).to_string()}
245                oninput={ctx.link().callback(|_|Msg::Sync)}
246            >
247                if ctx.props().code {
248                    <pre>{ value }</pre>
249                } else {
250                    { value }
251                }
252            </div>
253        }
254    }
255
256    /// Sync the value between internal, text field or details.
257    fn sync_from_edit(&mut self, ctx: &Context<Self>) {
258        if ctx.props().readonly || ctx.props().variant.is_inline() {
259            return;
260        }
261
262        let value = if self.expanded {
263            // from div to input
264            let ele: Option<Element> = self.details_ref.cast::<Element>();
265            ele.and_then(|ele| ele.text_content())
266                .unwrap_or_else(|| "".into())
267        } else {
268            // from input to div
269            let ele: Option<HtmlInputElement> = self.text_ref.cast::<HtmlInputElement>();
270            ele.map(|ele| ele.value()).unwrap_or_else(|| "".into())
271        };
272
273        log::debug!("New value: {}", value);
274
275        // sync back
276        match self.expanded {
277            true => {
278                if let Some(ele) = self.text_ref.cast::<HtmlInputElement>() {
279                    ele.set_value(&value);
280                }
281            }
282            false => {
283                if let Some(ele) = self.details_ref.cast::<Element>() {
284                    ele.set_text_content(Some(&value));
285                }
286            }
287        }
288
289        // sync to internal state
290
291        self.value = Some(value);
292    }
293}
294
295#[wasm_bindgen(inline_js=r#"
296export function copy_to_clipboard(value) {
297    try {
298        return window.navigator.clipboard.writeText(value);
299    } catch(e) {
300        console.log(e);
301        return Promise.reject(e)
302    }
303}
304"#)]
305#[rustfmt::skip] // required to keep the "async" keyword
306extern "C" {
307    #[wasm_bindgen(catch)]
308    async fn copy_to_clipboard(value: String) -> Result<(), JsValue>;
309}