mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-14 10:35:11 +00:00
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:
@@ -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',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Activities",
|
||||
"explorer": "File Explorer",
|
||||
"settings": "Settings & Help"
|
||||
}
|
||||
Reference in New Issue
Block a user