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

🎁 Dropdown #61

Merged
merged 10 commits into from
Aug 5, 2024
Merged
2 changes: 1 addition & 1 deletion .github/workflows/CI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
run: yarn install --immutable

- name: Type Check ʦ
run: yarn run type-check
run: yarn run typecheck

- name: Linting 🕵
run: yarn run lint
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ https://user-images.githubusercontent.com/3150694/232305636-e8b63780-4777-4d27-8
<td colspan="3"><a href="https://jetbrains.github.io/ui/controls/toolbar/">Toolbar</a></td>
<td>✅</td>
</tr>
<tr>
<td colspan="3"><a href="https://plugins.jetbrains.com/docs/intellij/drop-down.html">Drop-Down List</a></td>
<td>✅</td>
</tr>
<tr>
<td colspan="3"><a href="https://plugins.jetbrains.com/docs/intellij/combo-box.html">Combo Box</a></td>
<td>🚧</td>
</tr>
<tr>
<td rowspan="3"><a href="https://jetbrains.github.io/ui/controls/menu_list/">Menu List</a> <sup>1</sup></td>
</tr>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"packages/*"
],
"scripts": {
"type-check": "yarn workspaces foreach run type-check",
"typecheck": "yarn workspaces foreach run typecheck",
"test": "yarn workspaces foreach run test",
"cypress:component": "yarn workspaces foreach run cypress:component",
"cypress:e2e": "yarn workspaces foreach run cypress:e2e",
Expand Down
4 changes: 2 additions & 2 deletions packages/example-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
"scripts": {
"dev": "../../node_modules/.bin/parcel serve",
"serve": "PORT=1234 ../../node_modules/.bin/serve ./dist",
"jest:type-check": "tsc --project tsconfig.jest.json",
"jest:typecheck": "tsc --project tsconfig.jest.json",
"test": "jest",
"type-check": "tsc --project tsconfig.app.json && yarn run jest:type-check",
"typecheck": "tsc --project tsconfig.app.json && yarn run jest:typecheck",
"build": "../../node_modules/.bin/parcel build"
},
"source": "src/index.html",
Expand Down
41 changes: 28 additions & 13 deletions packages/example-app/src/ProjectView/actions/deleteAction.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "path";
import { selector, useRecoilCallback } from "recoil";
import { selector } from "recoil";
import React from "react";

import {
Expand All @@ -22,7 +22,6 @@ import { selectedNodesState } from "../ProjectView.state";
import { findRootPaths } from "../../path-utils";
import {
deleteDirCallback,
deleteFileCallback,
deleteFilesCallback,
} from "../../Project/fs-operations";
import { IntlMessageFormat } from "intl-messageformat";
Expand Down Expand Up @@ -52,7 +51,7 @@ export const deleteActionState = selector({
isDisabled: !get(activePathExistsState),
actionPerformed: getCallback(({ snapshot }) => async () => {
const deleteDir = getCallback(deleteDirCallback);
const deleteFile = getCallback(deleteFileCallback);
const deleteFiles = getCallback(deleteFilesCallback);
const selectedNodes = snapshot.getLoadable(selectedNodesState).getValue();
const windowManager = snapshot
.getLoadable(windowManagerRefState)
Expand Down Expand Up @@ -105,12 +104,31 @@ export const deleteActionState = selector({
});
if (confirmed) {
directories.forEach((pathname) => deleteDir(pathname));
filePaths.forEach((pathname) => deleteFile(pathname));
deleteFiles(filePaths);
}
} else {
windowManager?.open(({ close }) => (
<DeleteFilesConfirmationDialog filePaths={filePaths} close={close} />
));
windowManager
?.open<boolean>(({ close }) => (
<DeleteFilesConfirmationDialog
filePaths={filePaths}
close={close}
/>
))
.then((confirmed) => {
if (confirmed) {
requestAnimationFrame(() => {
// It's important that the focus is restored to the previously focused item, which typically is the
// file in project view, that's being deleted.
// If the file is deleted before the focus is restored, the DOM element that was going to be focused
// will be removed by the time focus restoration logic runs.
// Even though `windowManager.open()` was changed to return a promise which is supposed to be resolved
// **after** the modal window is closed,
// in the CI environment the focus is not properly restored.
// In lack of a better solution, the deletion is done async with a setTimeout.
deleteFiles(filePaths);
});
}
});
}
}),
}),
Expand All @@ -120,11 +138,9 @@ function DeleteFilesConfirmationDialog({
close,
filePaths,
}: {
close: () => void;
close: (confirmed?: true) => void;
filePaths: string[];
}) {
const deleteFiles = useRecoilCallback(deleteFilesCallback, []);

return (
<ModalWindow minWidth="content" minHeight="content">
<WindowLayout
Expand Down Expand Up @@ -165,15 +181,14 @@ function DeleteFilesConfirmationDialog({
left={<HelpButton onPress={() => notImplemented()}></HelpButton>}
right={
<>
<Button onPress={close}>Cancel</Button>
<Button onPress={() => close()}>Cancel</Button>
<Button
autoFocus
type="submit"
form="delete_dialog_form" // Using form in absence of built-in support for default button
variant="default"
onPress={() => {
deleteFiles(filePaths);
close();
close(true);
}}
>
Ok
Expand Down
89 changes: 41 additions & 48 deletions packages/example-app/src/SearchEverywhere/SearchEverywherePopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,52 @@ export function SearchEverywherePopup() {

const collectionRef = useRef<HTMLDivElement>(null);
const selectionManagerRef = useRef<SelectionManager>(null);
const onAction = (key: React.Key) => {
if (key === LOAD_MORE_ITEM_KEY) {
setSearchResultLimit((limit) => limit + SEARCH_RESULT_LIMIT);
const nextItem = searchResult[visibleSearchResult.length + 1];
if (nextItem) {
// nextItem is expected to always have value

// Timeout needed to let the item get rendered first. Could be done in an effect instead,
// if we want to avoid setTimeout
setTimeout(() => {
setSelectedKeys(new Set([nextItem.key]));
selectionManagerRef.current?.setFocusedKey(nextItem.key);
});
}
} else {
close();
// Making sure the popup is fully closed before the new action is performed. One edge case that can
// make a difference is actions like FindAction that open the same popup. By performing an action
// async, we make sure the popup is closed and reopened, which is good, because otherwise, the user
// won't get any feedback when choosing such actions.
setTimeout(() => {
const itemWrapper = searchResult.find((item) => item.key === key);
itemWrapper?.contributor.processSelectedItem(itemWrapper.item);
/**
* The 50ms timeout is a workaround for an issue in FocusScope:
* restoreFocus only works if the previously focused element is in the dom, when the focus
* scope is unmounted. In case of SearchEveryWhere, actions like "Rollback" open a modal
* window, which has a focus scope, when the window is opened, the currently focused
* element (which will be the one to restore focus to), is search everywhere dialog, which
* is immediately closed. So when the modal window is closed, it tries to move focus back
* to search everywhere dialog, which is long gone! It would be nice if FocusScope could
* track a chain of nodes to restore focus to.
* With this 50ms timeout, focus is first restored to where it was, after SearchEveryWhere
* is closed, and then the actions is performed, for focus restoration to work.
*/
}, 50);
}
};

const { collectionSearchInputProps } = useCollectionSearchInput({
collectionRef,
onAction,
selectionManager: selectionManagerRef.current,
});

const tips = useTips();

return (
<ContentAwarePopup
persistedBoundsState={searchEverywhereState.bounds}
Expand Down Expand Up @@ -391,53 +430,7 @@ export function SearchEverywherePopup() {
fillAvailableSpace
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
onAction={(key) => {
if (key === LOAD_MORE_ITEM_KEY) {
setSearchResultLimit(
(limit) => limit + SEARCH_RESULT_LIMIT
);
const nextItem =
searchResult[visibleSearchResult.length + 1];
if (nextItem) {
// nextItem is expected to always have value

// Timeout needed to let the item get rendered first. Could be done in an effect instead,
// if we want to avoid setTimeout
setTimeout(() => {
setSelectedKeys(new Set([nextItem.key]));
selectionManagerRef.current?.setFocusedKey(
nextItem.key
);
});
}
} else {
close();
// Making sure the popup is fully closed before the new action is performed. One edge case that can
// make a difference is actions like FindAction that open the same popup. By performing an action
// async, we make sure the popup is closed and reopened, which is good, because otherwise, the user
// won't get any feedback when choosing such actions.
setTimeout(() => {
const itemWrapper = searchResult.find(
(item) => item.key === key
);
itemWrapper?.contributor.processSelectedItem(
itemWrapper.item
);
/**
* The 50ms timeout is a workaround for an issue in FocusScope:
* restoreFocus only works if the previously focused element is in the dom, when the focus
* scope is unmounted. In case of SearchEveryWhere, actions like "Rollback" open a modal
* window, which has a focus scope, when the window is opened, the currently focused
* element (which will be the one to restore focus to), is search everywhere dialog, which
* is immediately closed. So when the modal window is closed, it tries to move focus back
* to search everywhere dialog, which is long gone! It would be nice if FocusScope could
* track a chain of nodes to restore focus to.
* With this 50ms timeout, focus is first restored to where it was, after SearchEveryWhere
* is closed, and then the actions is performed, for focus restoration to work.
*/
}, 50);
}
}}
onAction={onAction}
>
{({ key, item, contributor }) => {
if (key === LOAD_MORE_ITEM_KEY) {
Expand Down
16 changes: 8 additions & 8 deletions packages/example-app/src/VersionControl/FileStatusColor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,18 @@ export const useFileStatusColor = (filepath: string): string | undefined => {
return useStatusColor(fileStatus ?? "NOT_CHANGED");
};

export const FileStatusColor: React.FC<{ filepath: string }> = ({
children,
filepath,
}) => {
export const FileStatusColor: React.FC<{
filepath: string;
children?: React.ReactNode;
}> = ({ children, filepath }) => {
const color = useFileStatusColor(filepath);
return <span style={{ color }}>{children}</span>;
};

export const StatusColor: React.FC<{ status: FileStatus }> = ({
children,
status,
}) => {
export const StatusColor: React.FC<{
status: FileStatus;
children?: React.ReactNode;
}> = ({ children, status }) => {
const theme = useTheme() as Theme;
const color = theme.currentForegroundAware(useStatusColor(status));
return <span style={{ color }}>{children}</span>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,17 @@ export function BranchesTree({ tabKey }: { tabKey: string }) {
const selectionManagerRef = useRef<TreeSelectionManager>(null);
const [isInputFocused, setInputFocused] = useState(false);
const setBranchFilter = useSetRecoilState(vcsLogFilterCurrentTab.branch);
const onAction = (key: React.Key) => {
const branch = keyToBranch(`${key}`);
if (branch) {
setBranchFilter([branch]);
}
};

const { collectionSearchInputProps } = useCollectionSearchInput({
collectionRef: ref,
selectionManager: selectionManagerRef.current,
onAction,
});
/**
* TODO: remaining from search:
Expand Down Expand Up @@ -114,12 +122,7 @@ export function BranchesTree({ tabKey }: { tabKey: string }) {
onSelectionChange={setSelectedKeys}
expandedKeys={expandedKeys}
onExpandedChange={setExpandedKeys}
onAction={(key) => {
const branch = keyToBranch(`${key}`);
if (branch) {
setBranchFilter([branch]);
}
}}
onAction={onAction}
fillAvailableSpace
// speed search related props
showAsFocused={isInputFocused}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ function BranchesFilterMenu({
onBranchesSelected,
}: {
onBranchesSelected: (branches: string[]) => void;
menuProps: HTMLAttributes<HTMLElement>;
menuProps: Omit<HTMLAttributes<HTMLElement>, "autoFocus">;
}) {
const [favoriteBranches, setFavoriteBranches] = useState<BranchMenuItem[]>(
[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ const selectedKeysState = selector({
function VcsLogsDetailsViewOptionsMenu({
menuProps,
}: {
menuProps: HTMLAttributes<HTMLDivElement>;
menuProps: Omit<HTMLAttributes<HTMLDivElement>, "autoFocus">;
}) {
const group = useActionGroup(VIEW_OPTIONS_ACTION_GROUP_ID);
const selectedKeys = useRecoilValue(selectedKeysState);
Expand Down
12 changes: 7 additions & 5 deletions packages/example-app/src/fs/fs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ export const persistentFsPreference = localStorageSetting<"yes" | "no">(
// to assume any code requiring FS is executed via components rendered inside WaitForFs.
// Also, we can gather exports of this file into an interface to make it easier to provide two implementations, for
// browser and node.
const fsPromise: Promise<FSModuleWithPromises> = (persistentFsPreference.get() ===
"yes"
? createIndexedDBFS()
: createInMemoryFS()
const fsPromise: Promise<FSModuleWithPromises> = (
persistentFsPreference.get() === "yes"
? createIndexedDBFS()
: createInMemoryFS()
)
.then(initializeFS)
.then((theFs) => {
Expand Down Expand Up @@ -83,7 +83,9 @@ const fsResource = wrapPromise(fsPromise);
* Suspense aware wrapper for waiting for FS to be initialized. initialization API in some fs backends such as
* BrowserFs, is async. That's why it's safe to use this on a high level.
*/
export const WaitForFs: React.FC = ({ children }) => {
export const WaitForFs: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
fsResource.read();
return children as React.ReactElement;
};
Expand Down
2 changes: 2 additions & 0 deletions packages/example-app/src/tree-utils/groupByDirectory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const groupByDirectoryMergingPaths = createGroupByDirectory<
shouldCollapseDirectories: true,
});

jest.retryTimes(3);

describe("groupByDirectory", () => {
it("groups by directory", () => {
const y = change("/a/c/y.js");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export default {
argTypes: { },
} as Meta<<%= componentName %>Props>;

const Template: Story<<%= componentName %>Props> = (props) => {
const render = (props: <%= componentName %>Props) => {
return <<%= componentName %> {...props} />;
};

export const Default: Story<<%= componentName %>Props> = Template.bind({});
export const Default: StoryObj<<%= componentName %>Props> = {
render: render,
};
2 changes: 1 addition & 1 deletion packages/jui/cypress/e2e/file-actions.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const deleteFile = (filename: string) => {
cy.step(`Delete ${filename}`);
cy.findTreeNodeInProjectView(filename).click().should("be.focused");
cy.realPress("Backspace");
cy.findByRole("button", { name: "Ok" }).click();
cy.findByRole("button", { name: "Ok" }).realClick();
cy.findByRole("treeitem", { name: new RegExp(filename) }).should("not.exist");
};

Expand Down
Loading
Loading