Add Activities

This adds a new Activities tab list and view similar to VS code. The
Explorer and Settings are migrated to this view.
This commit is contained in:
David Lechner
2022-05-12 12:33:21 -05:00
parent 65623f801d
commit edd8ebfd64
22 changed files with 673 additions and 468 deletions
+104
View File
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useIsFirstRender } from 'usehooks-ts';
import { testRender } from '../../test';
import { Activity, useActivities } from './Activities';
afterEach(() => {
cleanup();
jest.resetAllMocks();
localStorage.clear();
});
type TestActivityProps = {
expectedActivity: Activity;
};
const TestActivity: React.VoidFunctionComponent<TestActivityProps> = ({
expectedActivity,
}) => {
const [selectedActivity, activitiesComponent] = useActivities();
if (useIsFirstRender()) {
expect(selectedActivity).toBe(expectedActivity);
}
return activitiesComponent;
};
describe('Activities', () => {
it('should select explorer by default', () => {
const [activities] = testRender(
<TestActivity expectedActivity={Activity.Explorer} />,
);
const tab = activities.getByRole('tab', { name: 'File Explorer' });
expect(tab).toHaveAttribute('aria-selected', 'true');
});
it('should use localStorage for default value', () => {
localStorage.setItem(
'activities.selectedActivity',
JSON.stringify(Activity.Settings),
);
const [activities] = testRender(
<TestActivity expectedActivity={Activity.Settings} />,
);
const tab = activities.getByRole('tab', { name: 'Settings & Help' });
expect(tab).toHaveAttribute('aria-selected', 'true');
});
it('should select none when clicking already selected tab', () => {
const [activities] = testRender(
<TestActivity expectedActivity={Activity.Explorer} />,
);
const explorerTab = activities.getByRole('tab', { name: 'File Explorer' });
for (const tab of activities.getAllByRole('tab')) {
expect(tab).toHaveAttribute(
'aria-selected',
tab === explorerTab ? 'true' : 'false',
);
}
userEvent.click(explorerTab);
for (const tab of activities.getAllByRole('tab')) {
expect(tab).toHaveAttribute('aria-selected', 'false');
}
});
it('should select new tab when clicking not already selected tab', () => {
const [activities] = testRender(
<TestActivity expectedActivity={Activity.Explorer} />,
);
const explorerTab = activities.getByRole('tab', { name: 'File Explorer' });
const settingsTab = activities.getByRole('tab', { name: 'Settings & Help' });
for (const tab of activities.getAllByRole('tab')) {
expect(tab).toHaveAttribute(
'aria-selected',
tab === explorerTab ? 'true' : 'false',
);
}
userEvent.click(settingsTab);
for (const tab of activities.getAllByRole('tab')) {
expect(tab).toHaveAttribute(
'aria-selected',
tab === settingsTab ? 'true' : 'false',
);
}
});
});
+138
View File
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Classes, Icon, IconName } from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { useCallback, useMemo } from 'react';
import { useLocalStorage } from 'usehooks-ts';
import { I18nId } from './i18n';
/** Indicates the selected activity. */
export enum Activity {
/** No activity is selected. */
None = 'activity.none',
/** The explorer activity is selected. */
Explorer = 'activity.explorer',
/** The settings activity is selected. */
Settings = 'activity.settings',
}
type ActivityTabProps = {
/** The label for the tab. */
label: string;
/** The icon for the tab. */
icon: IconName;
/** Controls the selected state of the tab. */
selected: boolean;
/** Callback called when the tab is clicked. */
onClick: () => void;
};
/**
* React component for tabs in {@link Activities}.
*/
const ActivityTab: React.VoidFunctionComponent<ActivityTabProps> = ({
label,
icon,
selected,
onClick,
}) => {
// not using Button component so we can set role to "tab"
return (
<div
role="tab"
title={label}
aria-selected={selected}
tabIndex={0}
className={[
'pb-activity-tablist-tab',
Classes.BUTTON,
Classes.MINIMAL,
selected ? Classes.INTENT_PRIMARY : undefined,
]
.filter((c) => c)
.join(' ')}
{...{ onClick }}
>
<Icon size={35} {...{ icon }} />
</div>
);
};
type ActivitiesProps = {
/** The currently selected activity. */
selectedActivity: Activity;
/** Callback called when a tab is clicked. */
onAction: (activity: Activity) => void;
};
/**
* React component that acts as a tab control to select activities.
*/
const Activities: React.VoidFunctionComponent<ActivitiesProps> = ({
selectedActivity,
onAction,
}) => {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useI18n();
return (
<div
aria-label={i18n.translate(I18nId.Title)}
role="tablist"
className="pb-activity-tablist"
>
<ActivityTab
label={i18n.translate(I18nId.Explorer)}
selected={selectedActivity === Activity.Explorer}
icon="document"
onClick={() => onAction(Activity.Explorer)}
/>
<ActivityTab
label={i18n.translate(I18nId.Settings)}
selected={selectedActivity === Activity.Settings}
icon="cog"
onClick={() => onAction(Activity.Settings)}
/>
</div>
);
};
/**
* React hook to get selected state and component.
* @returns The current selected activity (state) and the activity component.
*/
export function useActivities(): [
selectedActivity: Activity,
activitiesComponent: React.ReactElement,
] {
const [selectedActivity, setSelectedActivity] = useLocalStorage(
'activities.selectedActivity',
Activity.Explorer,
);
const handleAction = useCallback(
(newActivity: Activity) => {
// if activity is already selected, select none
if (selectedActivity === newActivity) {
setSelectedActivity(Activity.None);
} else {
// otherwise select the new activity
setSelectedActivity(newActivity);
}
},
[selectedActivity, setSelectedActivity],
);
const activitiesComponent = useMemo(
() => (
<Activities
selectedActivity={selectedActivity}
onAction={(a) => handleAction(a)}
/>
),
[Activities, selectedActivity, handleAction],
);
return [selectedActivity, activitiesComponent];
}
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { lookup } from '../../test';
import { I18nId } from './i18n';
import en from './translations/en.json';
describe('Ensure .json file has matches for I18nId', () => {
test.each(Object.values(I18nId))('%s', (id) => {
expect(lookup(en, id)).toBeDefined();
});
});
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
//
// Explorer translation keys.
export enum I18nId {
Title = 'title',
Explorer = 'explorer',
Settings = 'settings',
}
+5
View File
@@ -0,0 +1,5 @@
{
"title": "Activities",
"explorer": "File Explorer",
"settings": "Settings & Help"
}