mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
activities: use blueprintjs tabs for keyboard a18y
This commit is contained in:
@@ -4,9 +4,8 @@
|
||||
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';
|
||||
import Activities, { Activity } from './Activities';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -14,27 +13,9 @@ afterEach(() => {
|
||||
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 [activities] = testRender(<Activities />);
|
||||
|
||||
const tab = activities.getByRole('tab', { name: 'File Explorer' });
|
||||
|
||||
@@ -47,9 +28,7 @@ describe('Activities', () => {
|
||||
JSON.stringify(Activity.Settings),
|
||||
);
|
||||
|
||||
const [activities] = testRender(
|
||||
<TestActivity expectedActivity={Activity.Settings} />,
|
||||
);
|
||||
const [activities] = testRender(<Activities />);
|
||||
|
||||
const tab = activities.getByRole('tab', { name: 'Settings & Help' });
|
||||
|
||||
@@ -57,9 +36,7 @@ describe('Activities', () => {
|
||||
});
|
||||
|
||||
it('should select none when clicking already selected tab', () => {
|
||||
const [activities] = testRender(
|
||||
<TestActivity expectedActivity={Activity.Explorer} />,
|
||||
);
|
||||
const [activities] = testRender(<Activities />);
|
||||
|
||||
const explorerTab = activities.getByRole('tab', { name: 'File Explorer' });
|
||||
|
||||
@@ -78,9 +55,7 @@ describe('Activities', () => {
|
||||
});
|
||||
|
||||
it('should select new tab when clicking not already selected tab', () => {
|
||||
const [activities] = testRender(
|
||||
<TestActivity expectedActivity={Activity.Explorer} />,
|
||||
);
|
||||
const [activities] = testRender(<Activities />);
|
||||
|
||||
const explorerTab = activities.getByRole('tab', { name: 'File Explorer' });
|
||||
const settingsTab = activities.getByRole('tab', { name: 'Settings & Help' });
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { Classes, Icon, IconName } from '@blueprintjs/core';
|
||||
import './activities.scss';
|
||||
import { Icon, Tab, Tabs } from '@blueprintjs/core';
|
||||
import { useI18n } from '@shopify/react-i18n';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import Explorer from '../explorer/Explorer';
|
||||
import Settings from '../settings/Settings';
|
||||
import { I18nId } from './i18n';
|
||||
|
||||
/** Indicates the selected activity. */
|
||||
@@ -17,95 +20,13 @@ export enum Activity {
|
||||
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,
|
||||
}) => {
|
||||
const Activities: React.VoidFunctionComponent = () => {
|
||||
// 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,
|
||||
@@ -124,15 +45,75 @@ export function useActivities(): [
|
||||
[selectedActivity, setSelectedActivity],
|
||||
);
|
||||
|
||||
const activitiesComponent = useMemo(
|
||||
() => (
|
||||
<Activities
|
||||
selectedActivity={selectedActivity}
|
||||
onAction={(a) => handleAction(a)}
|
||||
/>
|
||||
),
|
||||
[Activities, selectedActivity, handleAction],
|
||||
);
|
||||
// HACK: fix keyboard focus when no tab is selected
|
||||
|
||||
return [selectedActivity, activitiesComponent];
|
||||
}
|
||||
const tabsRef = useRef<Tabs>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedActivity !== Activity.None) {
|
||||
// all is well
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error: using private property
|
||||
const tablist: HTMLDivElement = tabsRef.current?.tablistElement;
|
||||
|
||||
// istanbul-ignore-if: should not happen
|
||||
if (!tablist) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTab = tablist
|
||||
.getElementsByClassName('pb-activities-tablist-tab')
|
||||
.item(0);
|
||||
|
||||
// istanbul-ignore-if: should not happen
|
||||
if (!firstTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
firstTab.setAttribute('tabindex', '0');
|
||||
}, [tabsRef, selectedActivity]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
aria-label={i18n.translate(I18nId.Title)}
|
||||
vertical={true}
|
||||
className="pb-activities"
|
||||
selectedTabId={selectedActivity}
|
||||
onChange={handleAction}
|
||||
ref={tabsRef}
|
||||
>
|
||||
<Tab
|
||||
aria-label={i18n.translate(I18nId.Explorer)}
|
||||
className="pb-activities-tablist-tab"
|
||||
id={Activity.Explorer}
|
||||
title={
|
||||
<Icon
|
||||
htmlTitle={i18n.translate(I18nId.Explorer)}
|
||||
size={35}
|
||||
icon="document"
|
||||
/>
|
||||
}
|
||||
panel={<Explorer />}
|
||||
panelClassName="pb-activities-tabview"
|
||||
/>
|
||||
<Tab
|
||||
aria-label={i18n.translate(I18nId.Settings)}
|
||||
className="pb-activities-tablist-tab"
|
||||
id={Activity.Settings}
|
||||
title={
|
||||
<Icon
|
||||
htmlTitle={i18n.translate(I18nId.Settings)}
|
||||
size={35}
|
||||
icon="cog"
|
||||
/>
|
||||
}
|
||||
panel={<Settings />}
|
||||
panelClassName="pb-activities-tabview"
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default Activities;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
@use '@blueprintjs/core/lib/scss/variables' as bp;
|
||||
@use '../variables' as pb;
|
||||
|
||||
.pb-activities {
|
||||
& .#{bp.$ns}-tab-list {
|
||||
@include pb.background-contrast(6%);
|
||||
}
|
||||
|
||||
// override bluetprintjs styles
|
||||
.#{bp.$ns}-tabs.#{bp.$ns}-vertical > .#{bp.$ns}-tab-list &-tablist-tab {
|
||||
margin: bp.$pt-grid-size * 0.6;
|
||||
padding: unset;
|
||||
width: unset;
|
||||
line-height: unset;
|
||||
}
|
||||
|
||||
&-tabview {
|
||||
width: bp.$pt-grid-size * 25;
|
||||
padding: bp.$pt-grid-size;
|
||||
@include pb.background-contrast(0%);
|
||||
|
||||
// on small screens, make the activity view an overlay instead of inline
|
||||
@media screen and (max-width: pb.$narrow-screen-limit) {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
// FIXME: this should be a variable
|
||||
left: 47px;
|
||||
height: 100%;
|
||||
z-index: bp.$pt-z-index-overlay;
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-13
@@ -5,10 +5,8 @@ import { Classes } from '@blueprintjs/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import SplitterLayout from 'react-splitter-layout';
|
||||
import { useLocalStorage, useTernaryDarkMode } from 'usehooks-ts';
|
||||
import { Activity, useActivities } from '../activities/Activities';
|
||||
import Activities from '../activities/Activities';
|
||||
import Editor from '../editor/Editor';
|
||||
import Explorer from '../explorer/Explorer';
|
||||
import Settings from '../settings/Settings';
|
||||
import { useSettingIsShowDocsEnabled } from '../settings/hooks';
|
||||
import StatusBar from '../status-bar/StatusBar';
|
||||
import Terminal from '../terminal/Terminal';
|
||||
@@ -157,18 +155,13 @@ const App: React.VFC = () => {
|
||||
return () => removeEventListener('keydown', listener);
|
||||
}, []);
|
||||
|
||||
const [selectedActivity, activitiesComponent] = useActivities();
|
||||
|
||||
return (
|
||||
<div className="pb-app h-100 w-100 p-absolute">
|
||||
<div
|
||||
className="pb-app h-100 w-100 p-absolute"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="pb-app-body">
|
||||
{activitiesComponent}
|
||||
{selectedActivity !== Activity.None && (
|
||||
<div className="pb-app-activity-view">
|
||||
{selectedActivity === Activity.Explorer && <Explorer />}
|
||||
{selectedActivity === Activity.Settings && <Settings />}
|
||||
</div>
|
||||
)}
|
||||
<Activities />
|
||||
{/* need a container with position: relative; for SplitterLayout since it uses position: absolute; */}
|
||||
<div className="pb-app-main" style={{ position: 'relative' }}>
|
||||
<SplitterLayout
|
||||
|
||||
@@ -21,39 +21,6 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
$switcher-width: 50px;
|
||||
|
||||
.pb-activity-tablist {
|
||||
width: $switcher-width;
|
||||
$padding: bp.$pt-grid-size * 0.5;
|
||||
padding: $padding;
|
||||
gap: $padding;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
@include pb.background-contrast(6%);
|
||||
}
|
||||
|
||||
.pb-app-activity-view {
|
||||
width: 250px;
|
||||
overflow: hidden;
|
||||
padding: bp.$pt-grid-size;
|
||||
|
||||
// on small screens, make the activity view an overlay instead of inline
|
||||
@media screen and (max-width: pb.$narrow-screen-limit) {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: $switcher-width;
|
||||
height: 100%;
|
||||
z-index: bp.$pt-z-index-overlay;
|
||||
background-color: bp.$pt-app-background-color;
|
||||
|
||||
.#{bp.$ns}-dark & {
|
||||
background-color: bp.$pt-dark-app-background-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pb-app-main {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
Reference in New Issue
Block a user