Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(CodeSnippet): use tooltip styles for feedback and add animation #4741

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/components/src/components/code-snippet/_code-snippet.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
@import '../../globals/scss/vendor/@carbon/elements/scss/import-once/import-once';
@import '../../globals/scss/css--reset';
@import '../../globals/scss/theme-tokens';
@import '../../globals/scss/tooltip';
@import '../../globals/scss/keyframes';
@import 'mixins';

/// Code snippet styles
Expand Down Expand Up @@ -50,6 +52,37 @@
outline: none;
border: 2px solid $focus;
}

&::before {
@include tooltip--caret;
display: none;
}

.#{$prefix}--copy-btn__feedback {
@include tooltip--content('icon');
clip: auto;
margin: auto;
overflow: visible;
display: none;
}
@include tooltip--placement('icon', 'bottom', 'center');

&.#{$prefix}--copy-btn--animating::before,
emyarod marked this conversation as resolved.
Show resolved Hide resolved
&.#{$prefix}--copy-btn--animating .#{$prefix}--copy-btn__feedback {
display: block;
}

&.#{$prefix}--copy-btn--animating.#{$prefix}--copy-btn--fade-out::before,
&.#{$prefix}--copy-btn--animating.#{$prefix}--copy-btn--fade-out
.#{$prefix}--copy-btn__feedback {
animation: $duration--fast-02 motion(standard, productive) hide-feedback;
}

&.#{$prefix}--copy-btn--animating.#{$prefix}--copy-btn--fade-in::before,
&.#{$prefix}--copy-btn--animating.#{$prefix}--copy-btn--fade-in
.#{$prefix}--copy-btn__feedback {
animation: $duration--fast-02 motion(standard, productive) show-feedback;
}
}

.#{$prefix}--snippet--inline code {
Expand Down Expand Up @@ -213,6 +246,16 @@
border: none;
}

// TODO: remove copy button styles above
.#{$prefix}--snippet .#{$prefix}--copy-btn {
position: absolute;
top: 0;
right: 0;
@include carbon--font-family(
'sans'
); // Override inherited rule in code snippet
}

// Show more / less button
button.#{$prefix}--btn.#{$prefix}--snippet-btn--expand {
display: inline-flex;
Expand Down
38 changes: 21 additions & 17 deletions packages/components/src/components/code-snippet/code-snippet.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,19 @@

{{#is variant "inline"}}
<p>Here is an example of a text that a user would be reading. In this paragraph would be
<button data-copy-btn="" type="button" class="{{@root.prefix}}--snippet {{@root.prefix}}--snippet--inline {{@root.prefix}}--btn--copy{{#if light}} {{@root.prefix}}--snippet--light{{/if}}"
<button data-copy-btn type="button"
class="{{@root.prefix}}--snippet {{@root.prefix}}--snippet--inline {{#if light}} {{@root.prefix}}--snippet--light{{/if}}"
aria-label="Copy code" tabindex="0">
<code>inline code</code>
<div class="{{@root.prefix}}--btn--copy__feedback" role="alert" data-feedback="Copied!"></div>
<span class="{{@root.prefix}}--assistive-text {{@root.prefix}}--copy-btn__feedback">Copied!</span>
</button>
that the user could look at and copy in to their code editor.</p>
{{else}}

<div class="{{@root.prefix}}--snippet {{@root.prefix}}--snippet--{{variant}}" {{#is variant "multi"}}
data-code-snippet{{/is}}> <div class="{{@root.prefix}}--snippet-container" aria-label="Code Snippet Text">
<pre>
data-code-snippet{{/is}}>
<div class="{{@root.prefix}}--snippet-container" aria-label="Code Snippet Text">
<pre>
<code>@mixin grid-container {
width: 100%;
padding-right: padding(mobile);
Expand All @@ -40,22 +42,24 @@
floating: 10000
);</code>
</pre>
</div>
<button data-copy-btn class="{{@root.prefix}}--snippet-button" type="button" aria-label="Copy" tabindex="0">
{{ carbon-icon 'Copy16' class=(add @root.prefix '--snippet__icon')}}
<div class="{{@root.prefix}}--btn--copy__feedback" role="alert" data-feedback="Copied!"></div>
</button>
{{#is variant "multi"}}
<button class="{{@root.prefix}}--btn {{@root.prefix}}--btn--ghost {{@root.prefix}}--btn--sm {{@root.prefix}}--snippet-btn--expand"
type="button">
<span class="{{@root.prefix}}--snippet-btn--text" data-show-more-text="Show more" data-show-less-text="Show less">Show
more</span>
{{ carbon-icon 'ChevronDown16' class=(add
</div>
<button data-copy-btn class="{{@root.prefix}}--copy-btn" type="button" tabindex="0">
<span class="{{@root.prefix}}--assistive-text {{@root.prefix}}--copy-btn__feedback">Copied!</span>
{{ carbon-icon 'Copy16' class=(add @root.prefix '--snippet__icon' hidden="true")}}
</button>
{{#is variant "multi"}}
<button
class="{{@root.prefix}}--btn {{@root.prefix}}--btn--ghost {{@root.prefix}}--btn--sm {{@root.prefix}}--snippet-btn--expand"
type="button">
<span class="{{@root.prefix}}--snippet-btn--text" data-show-more-text="Show more"
data-show-less-text="Show less">Show
more</span>
{{ carbon-icon 'ChevronDown16' class=(add
(add @root.prefix '--icon-chevron--down')
(add ' ' (add @root.prefix '--snippet__icon'))
)
aria-label="Show more icon"}}
</button>
{{/is}}
</button>
{{/is}}
</div>
{{/is}}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
@import '../../globals/scss/vendor/@carbon/elements/scss/import-once/import-once';
@import '../../globals/scss/css--reset';
@import '../button/button';
@import 'keyframes';
@import '../../globals/scss/tooltip';
@import '../../globals/scss/keyframes';

@include exports('copy-button') {
.#{$prefix}--btn--copy {
Expand Down Expand Up @@ -85,6 +86,11 @@
height: $carbon--spacing-08;
width: $carbon--spacing-08;
background-color: $ui-01;
cursor: pointer;

&:hover {
background-color: $hover-ui;
}

&::before {
@include tooltip--caret;
Expand Down
1 change: 0 additions & 1 deletion packages/react/src/components/CodeSnippet/CodeSnippet.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ function CodeSnippet({
</code>
</div>
<CopyButton
className={`${prefix}--snippet-button`}
onClick={onClick}
feedback={feedback}
iconDescription={copyButtonDescription}
Expand Down
30 changes: 12 additions & 18 deletions packages/react/src/components/Copy/Copy-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,38 +52,32 @@ describe('Copy', () => {
it('Should be able to specify the feedback message', () => {
const feedbackWrapper = mount(<Copy feedback="Copied!" />);
expect(
feedbackWrapper.find(`.${prefix}--btn--copy__feedback`).props()[
'data-feedback'
]
feedbackWrapper.find(`.${prefix}--copy-btn__feedback`).text()
).toBe('Copied!');
});
});

describe('Renders feedback as expected', () => {
it('Should make the feedback visible', () => {
const feedbackWrapper = mount(<Copy feedback="Copied!" />);
const feedback = () =>
feedbackWrapper.find(`.${prefix}--btn--copy__feedback`);
expect(
feedback().hasClass(`${prefix}--btn--copy__feedback--displayed`)
).toBe(false);
feedbackWrapper.setState({ showFeedback: true });
expect(
feedback().hasClass(`${prefix}--btn--copy__feedback--displayed`)
).toBe(true);
const feedback = feedbackWrapper.find(`.${prefix}--copy-btn__feedback`);
expect(feedback).toBeFalsy;
feedbackWrapper.simulate('click');
expect(feedback).toBeTruthy;
});

it('Should show feedback for a limited amount of time', () => {
const feedbackWrapper = mount(
<Copy feedback="Copied!" feedbackTimeout={5000} />
);
expect(feedbackWrapper.state().showFeedback).toBe(false);
feedbackWrapper.simulate('click');
expect(feedbackWrapper.state().showFeedback).toBe(true);
expect(setTimeout.mock.calls.length).toBe(2);
expect(setTimeout.mock.calls[1][1]).toBe(5000);
jest.runAllTimers();
expect(feedbackWrapper.state().showFeedback).toBe(false);
const copyButton = feedbackWrapper.find('button');
expect(copyButton.hasClass(`${prefix}--copy-btn--animating`)).toBe(true);
setTimeout(() => {
expect(copyButton.hasClass(`${prefix}--copy-btn--animating`)).toBe(
false
);
}, 5220); // 5000 + 2 * 110 (transition duration)
});
});

Expand Down
160 changes: 85 additions & 75 deletions packages/react/src/components/Copy/Copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,91 +6,101 @@
*/

import PropTypes from 'prop-types';
import React, { Component } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import debounce from 'lodash.debounce';
import classnames from 'classnames';
import { settings } from 'carbon-components';
import { composeEventHandlers } from '../../tools/events';

const { prefix } = settings;

export default class Copy extends Component {
static propTypes = {
/**
* Pass in content to be rendred in the underlying <button>
*/
children: PropTypes.node,

/**
* Specify an optional className to be applied to the underlying <button>
*/
className: PropTypes.string,

/**
* Specify the string that is displayed when the button is clicked and the
* content is copied
*/
feedback: PropTypes.string,

/**
* Specify the time it takes for the feedback message to timeout
*/
feedbackTimeout: PropTypes.number,

/**
* Specify an optional `onClick` handler that is called when the underlying
* <button> is clicked
*/
onClick: PropTypes.func,
export default function Copy({
children,
className,
feedback,
feedbackTimeout,
onAnimationEnd,
onClick,
...other
}) {
const [animation, setAnimation] = useState('');
const classNames = classnames(className, {
[`${prefix}--copy-btn--animating`]: animation,
[`${prefix}--copy-btn--${animation}`]: animation,
});
const handleFadeOut = useCallback(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense for this work to be in an effect parameterized by animation changing? Something like:

useEffect(() => {
  if (animation !== 'fade-in') {
    return;
  }

  const timeoutId = window.setTimeout(() => {
    setAnimation('fade-out');
  }, [feedbackTimeout]);

  return () => {
    window.clearTimeout(timeoutId);
  };
}, [animation, feedbackTimeout]);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this eliminate our debouncing then? I moved towards using debounce to eliminate the need for managing timers and refs

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, is handleFadeOut called multiple time which is why we need debounce here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah the tooltip needs to persist if the button is clicked again and should fade out after the timeout expires for the most recent click of the button. with useEffect all clicks following the first click are ignored until the tooltip fades out (based on the timeout from the very first click). so we debounce the click and fadeout handlers because they can fire multiple times

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this seems like a use-case we could achieve with useEffect, right? What would make the clicks become ignored? It seemed like on click we want to update the animation state and then enqueue a timeout, cancelling it if the button is clicked and creating a new one. Is there something else that I'm missing here? 👀

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the timeout management occurs in useEffect doesn't that require another render? patching with useEffect results in clearTimeout firing after the timeout has already completed

gif

debounce(() => {
setAnimation('fade-out');
}, feedbackTimeout),
[feedbackTimeout]
);
const handleClick = useCallback(() => {
setAnimation('fade-in');
handleFadeOut();
}, [handleFadeOut]);
const handleAnimationEnd = event => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style-nit: use function declarations for event handlers

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just wondering is there a reason behind this? a function declaration would be hoisted, so in that case we might as well be advocating for all function declarations to be made at the top of their scopes

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think at this point it's just as a style consistency point tbh, if we have hard numbers one way or another could definitely update the style guide!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if it isn't part of the style guide yet I think there should be a discussion about that rule. I lean more towards function expressions in these cases to avoid function hoisting

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@emyarod it is in the style guide over in: https://github.com/carbon-design-system/carbon/blob/master/docs/style.md#writing-a-component

Feel free to bring up reasons against it in our Slack channel or a PR! We only use this style since it's what React is currently using in their official documentation and there weren't any glaring reasons to stray from it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see anything about avoiding function expressions for event handlers in the style guide or the React documentation. can you elaborate on that?

if (event.animationName === 'hide-feedback') {
setAnimation('');
}
};

static defaultProps = {
feedback: 'Copied!',
feedbackTimeout: 2000,
onClick: () => {},
};
useEffect(
() => () => {
handleFadeOut.cancel();
},
[handleFadeOut]
);

state = {
showFeedback: false,
};
return (
<button
type="button"
className={classNames}
onClick={composeEventHandlers([onClick, handleClick])}
onAnimationEnd={composeEventHandlers([
onAnimationEnd,
handleAnimationEnd,
])}
{...other}>
{children}
<span
className={`${prefix}--assistive-text ${prefix}--copy-btn__feedback`}>
{feedback}
</span>
</button>
);
}

/* istanbul ignore next */
componentWillUnmount() {
if (typeof this.timeoutId !== 'undefined') {
clearTimeout(this.timeoutId);
delete this.timeoutId;
}
}
Copy.propTypes = {
emyarod marked this conversation as resolved.
Show resolved Hide resolved
/**
* Pass in content to be rendred in the underlying <button>
*/
children: PropTypes.node,

handleClick = evt => {
this.setState({ showFeedback: true });
this.timeoutId = setTimeout(() => {
this.setState({ showFeedback: false });
}, this.props.feedbackTimeout);
/**
* Specify an optional className to be applied to the underlying <button>
*/
className: PropTypes.string,

this.props.onClick(evt);
}; // eslint-disable-line no-unused-vars
/**
* Specify the string that is displayed when the button is clicked and the
* content is copied
*/
feedback: PropTypes.string,

render() {
const {
className,
feedback,
children,
feedbackTimeout, // eslint-disable-line no-unused-vars
onClick, // eslint-disable-line no-unused-vars
...other
} = this.props;
const feedbackClassNames = classnames(`${prefix}--btn--copy__feedback`, {
[`${prefix}--btn--copy__feedback--displayed`]: this.state.showFeedback,
});
/**
* Specify the time it takes for the feedback message to timeout
*/
feedbackTimeout: PropTypes.number,

return (
<button
type="button"
className={className}
onClick={this.handleClick}
{...other}>
{children}
<div className={feedbackClassNames} data-feedback={feedback} />
</button>
);
}
}
/**
* Specify an optional `onClick` handler that is called when the underlying
* <button> is clicked
*/
onClick: PropTypes.func,
};

Copy.defaultProps = {
emyarod marked this conversation as resolved.
Show resolved Hide resolved
feedback: 'Copied!',
feedbackTimeout: 2000,
onClick: () => {},
};
Loading