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

[ResourceList-BulkAction][Checkbox] Handle focus on bulk actions and converts checkbox into a functional component. #2138

Merged
merged 1 commit into from
Oct 4, 2019

Conversation

dleroux
Copy link
Contributor

@dleroux dleroux commented Sep 13, 2019

WHY are these changes introduced?

Fixes #792

WHAT is this pull request doing?

I believe this is the last of the ResourceList a11y issues outline in [#792].

In order to switch focus between the plain CheckableButton checkbox and the ones inside the BulkActions, we needed to keep track of the button that was clicked and which of the 3 possible buttons that would receive focus.

  1. The 3 buttons are tracked via context.
  2. handleToggleAll is only called when the bulk action or plain button are clicked. From there, depending on screen size we focus the right button.
  3. To enable focus on the checkbox I needed to forwardRef on the checkbox. According to this: https://reactjs.org/docs/forwarding-refs.html#note-for-component-library-maintainers This would be a breaking changes. Discussed with @AndrewMusgrave and we're not sure we agree in our case. @BPScott thoughts?

How to 🎩

🖥 Local development instructions
🗒 General tophatting guidelines
📄 Changelog guidelines

Copy-paste this code in playground/Playground.tsx:
import React from 'react';
import {
  Page,
  ResourceList,
  Card,
  Avatar,
  ResourceItem,
  TextStyle,
} from '../src';

export function Playground() {
  return (
    <Page>
      <ResourceListExample />
    </Page>
  );
}
class ResourceListExample extends React.Component {
  state = {
    selectedItems: [],
  };

  handleSelectionChange = (selectedItems) => {
    this.setState({selectedItems});
  };

  renderItem = (item) => {
    const {id, url, name, location} = item;
    const media = <Avatar customer size="medium" name={name} />;
    return (
      <ResourceItem
        id={id}
        url={url}
        media={media}
        accessibilityLabel={`View details for ${name}`}
      >
        <h3>
          <TextStyle variation="strong">{name}</TextStyle>
        </h3>
        <div>{location}</div>
      </ResourceItem>
    );
  };

  render() {
    const resourceName = {
      singular: 'customer',
      plural: 'customers',
    };
    const items = [
      {
        id: 341,
        url: 'customers/341',
        name: 'Mae Jemison',
        location: 'Decatur, USA',
      },
      {
        id: 256,
        url: 'customers/256',
        name: 'Ellen Ochoa',
        location: 'Los Angeles, USA',
      },
    ];
    const promotedBulkActions = [
      {
        content: 'Edit customers',
        onAction: () => console.log('Todo: implement bulk edit'),
      },
    ];
    const bulkActions = [
      {
        content: 'Add tags',
        onAction: () => console.log('Todo: implement bulk add tags'),
      },
      {
        content: 'Remove tags',
        onAction: () => console.log('Todo: implement bulk remove tags'),
      },
      {
        content: 'Delete customers',
        onAction: () => console.log('Todo: implement bulk delete'),
      },
    ];
    return (
      <Card>
        <ResourceList
          resourceName={resourceName}
          items={items}
          renderItem={this.renderItem}
          selectedItems={this.state.selectedItems}
          onSelectionChange={this.handleSelectionChange}
          promotedBulkActions={promotedBulkActions}
          bulkActions={bulkActions}
        />
      </Card>
    );
  }
}

🎩 checklist

@BPScott BPScott mentioned this pull request Sep 13, 2019
const {onChange, id, disabled} = this.props;

if (onChange == null || this.inputNode.current == null || disabled) {
const ImperitaveCheckbox: RefForwardingComponent<
Copy link
Member

Choose a reason for hiding this comment

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

I think we'd be able to sidestep this explicit typing stuff if we define this inline rather than a separate function

export const Checkbox = React.forwardRef(function Checkbox({
  /* Props here */
}) {
  /* Blah */
});

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I couldn't get this to work for some reason. I'll give it another go.

const inputNode = useRef<HTMLInputElement>(null);

const generatedId = useRef(getUniqueID());
const id = idProp || generatedId.current;
Copy link
Member

Choose a reason for hiding this comment

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

use useUniqueId instead of createUniqueIDFactory and ref dancing

@dleroux dleroux force-pushed the 792-resource-list-focus branch 9 times, most recently from 52e20ab to 693b755 Compare September 16, 2019 13:41
@dleroux dleroux changed the title [WIP][ResourceList-BulkAction][Checkbox] Handle focus on bulk actions and converts checkbox into a functional component. [ResourceList-BulkAction][Checkbox] Handle focus on bulk actions and converts checkbox into a functional component. Sep 16, 2019
@dleroux
Copy link
Contributor Author

dleroux commented Sep 16, 2019

After a discussion wit @AndrewMusgrave and @BPScott regarding this being a breaking change we agreed that in our case we wouldn't consider this a breaking change because even though the type for Checkbox is changing, we did not expose any public API or forwardRef on Checkbox, therefore, this shouldn't affect anything we support today.

if (onChange == null || inputNode.current == null || disabled) {
return;
}
onChange(!inputNode.current.checked, id as any);
Copy link
Member

Choose a reason for hiding this comment

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

It looks like id is now typed to be string so do you need that as any?

@dleroux dleroux force-pushed the 792-resource-list-focus branch from e326ded to a387db2 Compare September 17, 2019 14:33
@dleroux dleroux requested a review from BPScott September 17, 2019 14:33
Copy link
Member

@AndrewMusgrave AndrewMusgrave left a comment

Choose a reason for hiding this comment

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

Looks like we might be able to infer the imperative handler type with one of these

@@ -27,6 +27,8 @@ const MAX_PROMOTED_ACTIONS = 2;
export interface BulkActionsProps {
/** Visually hidden text for screen readers */
accessibilityLabel?: string;
/** Whether to render the small screen BulkActions or not */
smallScreen?: boolean;
Copy link
Member

Choose a reason for hiding this comment

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

Rather than adding this to the public API - should we use #2117 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this case smallScreen is const random number for the resourceList. I could duplicate the code or add it to context, but I opted for a prop. What do you think?

@@ -363,11 +366,12 @@ class BulkActions extends React.PureComponent<CombinedProps, State> {
disabled,
};

const smallScreenGroup = (
const smallScreenGroup = smallScreen ? (
Copy link
Member

Choose a reason for hiding this comment

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

Love how we're only rendering these when we need to 😍

const bulkActions = mountWithAppProvider(
<BulkActions {...bulkActionProps} selectMode />,
);
const smallGroup = findByTestID(bulkActions, 'smallGroup');
Copy link
Member

Choose a reason for hiding this comment

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

An alternative to testID's could be find(Transition) since you were worried about using them

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wanted to use findWhere and use the key, but the keys get striped. Just using Find doesn't guarantee us which will be found.

const largeGroup = findByTestID(bulkActions, 'largeGroup');

expect(smallGroup.exists()).toBe(false);
expect(largeGroup.exists()).toBe(true);
Copy link
Member

Choose a reason for hiding this comment

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

expect(bulkActions ).toContainReactComponentTimes(Transition, 1);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same as above. No guarantee which was found.

'input[type="checkbox"]',
);

expect(checkBox.instance()).toBe(document.activeElement);
Copy link
Member

Choose a reason for hiding this comment

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

instance returns a react component, however, getDOMNode returns the dom element

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Interesting how this did pass though.

/>,
);

const deselectAllCheckableButton = resourceList.findWhere(
Copy link
Member

Choose a reason for hiding this comment

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

With react-testing you can automatically filter by props in the matcher

button: CheckableButtonNode,
) => {
const {checkableButtons} = this.state;
checkableButtons.set(key, button);
Copy link
Member

Choose a reason for hiding this comment

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

Uh-oh we're mutating state here 🚫

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🙈

@dleroux dleroux force-pushed the 792-resource-list-focus branch 2 times, most recently from 15d8a4d to 33059ae Compare September 17, 2019 22:58
Copy link
Member

@BPScott BPScott left a comment

Choose a reason for hiding this comment

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

Conversion of Checkbox looks good. I don't know enough about ResourceList to be able to give a decent review on that.

One small note inline about note about not making CheckboxHandles public but other than that I'm happy

@@ -28,7 +28,7 @@ export {Caption, CaptionProps} from './Caption';

export {Card, CardProps} from './Card';

export {Checkbox, CheckboxProps} from './Checkbox';
export {Checkbox, CheckboxProps, CheckboxHandles} from './Checkbox';
Copy link
Member

Choose a reason for hiding this comment

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

I'd like to avoid exporting this publicly, people shouldn't need it

Copy link
Contributor Author

@dleroux dleroux Sep 18, 2019

Choose a reason for hiding this comment

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

fair enough. I had to disable strict-component-boundaries in the types file to do this.

@dleroux dleroux force-pushed the 792-resource-list-focus branch 3 times, most recently from c8a6364 to 7d79ac3 Compare September 18, 2019 12:37
@dleroux dleroux requested a review from BPScott September 18, 2019 18:08
@dleroux dleroux force-pushed the 792-resource-list-focus branch from 7d79ac3 to 6c6ac69 Compare September 18, 2019 18:08
@dpersing
Copy link
Contributor

👋 @BPScott and @AndrewMusgrave, any chance you've taken a look at this yet?

@dleroux dleroux force-pushed the 792-resource-list-focus branch 2 times, most recently from 4fe585d to dcdb251 Compare September 24, 2019 13:03
Copy link
Member

@AndrewMusgrave AndrewMusgrave left a comment

Choose a reason for hiding this comment

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

Using the keyboard it looks like the correct checkbox has focus however it doesn't show visually (https://cl.ly/12e433c1102a). I'm using spacebar to toggle. Left a few comments with thoughts/questions, no strong feelings so do with it if you please 😄 Awesome job! This looks amazing 🎉

);

const selectAllCheckableButton = resourceList.findWhere(
(wrap) => wrap.is(CheckableButton) && wrap.prop('plain') === true,
Copy link
Member

Choose a reason for hiding this comment

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

No strong feelings, we usually coerce these types as values rather than compare against a boolean but I'll leave the choice up to you

Suggested change
(wrap) => wrap.is(CheckableButton) && wrap.prop('plain') === true,
(wrap) => wrap.is(CheckableButton) && wrap.prop('plain'),

.findWhere(
(wrap) => wrap.is(CheckableButton) && !wrap.prop('plain'),
)
.find('input[type="checkbox"]');
Copy link
Member

Choose a reason for hiding this comment

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

Just curious why you didn't include this in findWhere or before the findWhere (both should reduce the number of operations, although probably by a negligible amount)(not asking for a code change, just curious)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🤔 the findWhere will return the wrap based on the filtering condition. I'm after the checkbox inside the wrap. Putting the find before would not allow me to findWhere based on parents asaik. In any case, this has been simplified, because as you suggested I made the findWhere into a function.

/>,
);

const deselectAllCheckableButton = resourceList.findWhere(
Copy link
Member

Choose a reason for hiding this comment

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

Seems like we use this logic a lot, might be worth bringing it out into a function

function deselectAllCheckableButtons(wrapper) {} | function selectedAllCheckableButtons(wrapper)


const selectAllCheckableCheckbox = resourceList
.findWhere(
(wrap) => wrap.is(CheckableButton) && wrap.prop('plain') === true,
Copy link
Member

Choose a reason for hiding this comment

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

Same comment as a few . above

Suggested change
(wrap) => wrap.is(CheckableButton) && wrap.prop('plain') === true,
(wrap) => wrap.is(CheckableButton) && wrap.prop('plain'),

@@ -9,6 +14,7 @@ export interface CheckableButtonProps {
label?: string;
selected?: boolean | 'indeterminate';
selectMode?: boolean;
smallScreen?: boolean;
Copy link
Member

Choose a reason for hiding this comment

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

Should we create an issue to add RL smallScreen to media query provider and remove this prop?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you feel like we should we could do that. I actually don't mind the prop here making this a controllable dumb component.


let currentKey: CheckableButtonKey = 'plain';

if (plain) {
Copy link
Member

Choose a reason for hiding this comment

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

Curious what you think about a switch statement here?

case plain:
  break;
case smallScreen:
  currentKey = 'bulkSm';
  break;
default:
  currentKey = 'bulkLg';

or if there's a condition that lets us remove currentKey = 'plain'; or default currentKey to undefined. Just some written thoughts, don't let this block you from 🐑 🇮🇹

Copy link
Contributor Author

Choose a reason for hiding this comment

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

in this case, we're switching on 2 different props. I can simply by defaulting to the else instead.

export type ResourceListSelectedItems = string[] | 'All';

export type CheckableButtonNode = CheckboxHandles;
Copy link
Member

Choose a reason for hiding this comment

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

Do we need this type?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not really I guess. I wasn't thinking it made sense just because of the naming, but I can just us the CheckboxHandles. I'll remove it since it brings up the question.

@dleroux dleroux force-pushed the 792-resource-list-focus branch from dcdb251 to 78cd5f2 Compare September 25, 2019 18:56
@dleroux
Copy link
Contributor Author

dleroux commented Sep 25, 2019

Using the keyboard it looks like the correct checkbox has focus however it doesn't show visually

Not sure why this is, everything checks out and the input does get focus. For some reason with the keyboard it doesn't trigger the visual change on the checkbox. Tried forcing a reflow and it doesn't work either. Sadly the only thing that does work is requestAnimationFrame, so I had to mock it.

The rest of the suggestions have been applied.

@dleroux dleroux force-pushed the 792-resource-list-focus branch 2 times, most recently from 0aba4d3 to d5636b3 Compare September 30, 2019 15:36
@dpersing
Copy link
Contributor

or some reason with the keyboard it doesn't trigger the visual change on the checkbox.

.Checkbox-Input_1uEY6 {
    position: absolute !important;
    top: 0;
    clip: rect(1px, 1px, 1px, 1px) !important;
    overflow: hidden !important;
    height: 1px !important;
    width: 1px !important;
    padding: 0 !important;
    border: 0 !important;
}

@dleroux I suspect this is because the actual checkbox is visually hidden, and the visually mocked on is getting updated when the user unchecks it. Focus is on the "old" version of the visual checkbox.

If we can avoid hiding the actual checkbox visually, this issue should go away.

@AndrewMusgrave
Copy link
Member

AndrewMusgrave commented Sep 30, 2019

Is this ready for another round of reviews?

@dleroux
Copy link
Contributor Author

dleroux commented Oct 2, 2019

Yes it is.

if (onSelectionChange) {
onSelectionChange(newlySelectedItems);
}

// setTimeout ensures execution after the Transition on BulkActions
setTimeout(() => {
Copy link
Member

Choose a reason for hiding this comment

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

cc/ @beefchimi similar to what we were looking at yesterday

Copy link
Contributor

Choose a reason for hiding this comment

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

Similar, except this is able to get away with a 0ms Timeout 😄

In this case, is requestAnimationFrame an alternative solution the problem? Also... is this needed specifically because of being nested in react-transition-group?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it is needed because of react-transition-group. They trigger their transition using setTimeout. So using setTimeout here ensures that theirs is called first. requestAnimationFrame was working, but because they are using setTimeout we felt it was better here.

Copy link
Member

@AndrewMusgrave AndrewMusgrave left a comment

Choose a reason for hiding this comment

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

🎩 looked ✅

@dleroux dleroux force-pushed the 792-resource-list-focus branch from d5636b3 to a65d5d3 Compare October 3, 2019 19:05
@github-actions
Copy link
Contributor

github-actions bot commented Oct 3, 2019

Results

💦 Potential splash zone of changes introduced to src/**/*.tsx in this pull request:

Files modified13
Files potentially affected135

Details

All files potentially affected (total: 135)
UNRELEASED.md (total: 0)

Files potentially affected (total: 0)

🧩 src/components/Checkbox/Checkbox.tsx (total: 5)

Files potentially affected (total: 5)

🧩 src/components/Checkbox/index.ts (total: 5)

Files potentially affected (total: 5)

🧩 src/components/Checkbox/tests/Checkbox.test.tsx (total: 0)

Files potentially affected (total: 0)

🧩 src/components/ResourceList/ResourceList.tsx (total: 0)

Files potentially affected (total: 0)

🧩 src/components/ResourceList/components/BulkActions/BulkActions.tsx (total: 1)

Files potentially affected (total: 1)

🧩 src/components/ResourceList/components/BulkActions/tests/BulkActions.test.tsx (total: 0)

Files potentially affected (total: 0)

🧩 src/components/ResourceList/components/CheckableButton/CheckableButton.tsx (total: 2)

Files potentially affected (total: 2)

🧩 src/components/ResourceList/tests/ResourceList.test.tsx (total: 0)

Files potentially affected (total: 0)

🧩 src/types.ts (total: 135)

Files potentially affected (total: 135)

🧩 src/utilities/resource-list/context.ts (total: 7)

Files potentially affected (total: 7)

🧩 src/utilities/resource-list/index.ts (total: 6)

Files potentially affected (total: 6)

🧩 src/utilities/resource-list/types.ts (total: 8)

Files potentially affected (total: 8)


This comment automatically updates as changes are made to this pull request.
Feedback, troubleshooting: open an issue or reach out on Slack in #polaris-tooling.

@dleroux dleroux merged commit f323ec9 into master Oct 4, 2019
@dleroux dleroux deleted the 792-resource-list-focus branch October 4, 2019 12:33
@dleroux dleroux temporarily deployed to production October 8, 2019 11:46 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[a11y] Resource list has labeling and focus issues
5 participants