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

[Major] Select listbox now uses Radix UI popover #1114

Merged
merged 12 commits into from
Jun 30, 2022
Merged
1 change: 1 addition & 0 deletions lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@material-ui/lab": "4.0.0-alpha.17",
"@material-ui/pickers": "3.2.2",
"@material-ui/styles": "4.0.2",
"@radix-ui/react-popover": "^0.1.6",
"@types/styled-components": "^5.1.24",
"@types/uuid": "^8.3.4",
"color": "^3.1.3",
Expand Down
16 changes: 16 additions & 0 deletions lib/src/paginator/Paginator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@ import { render, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import DxcPaginator from "./Paginator";

// Mocking DOMRect for Radix Primitive Popover
global.globalThis = global;
global.ResizeObserver = class ResizeObserver {
constructor(cb) {
this.cb = cb;
}
observe() {
this.cb([{ borderBoxSize: { inlineSize: 0, blockSize: 0 } }]);
}
unobserve() {}
};

global.DOMRect = {
fromRect: () => ({ top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }),
};

describe("Paginator component tests", () => {
test("Paginator renders with default values", () => {
const { getByText } = render(<DxcPaginator></DxcPaginator>);
Expand Down
16 changes: 16 additions & 0 deletions lib/src/resultsetTable/ResultsetTable.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ import userEvent from "@testing-library/user-event";

import DxcResultsetTable from "./ResultsetTable";

// Mocking DOMRect for Radix Primitive Popover
global.globalThis = global;
global.ResizeObserver = class ResizeObserver {
constructor(cb) {
this.cb = cb;
}
observe() {
this.cb([{ borderBoxSize: { inlineSize: 0, blockSize: 0 } }]);
}
unobserve() {}
};

global.DOMRect = {
fromRect: () => ({ top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }),
};

const columns = [
{
displayValue: "Id",
Expand Down
240 changes: 124 additions & 116 deletions lib/src/select/Listbox.tsx
Original file line number Diff line number Diff line change
@@ -1,135 +1,143 @@
import React from "react";
import React, { useLayoutEffect, useRef } from "react";
import styled, { ThemeProvider } from "styled-components";
import useTheme from "../useTheme";
import useTranslatedLabels from "../useTranslatedLabels";
import { ListboxProps, ListboxRefType } from "./types";
import { ListboxProps } from "./types";
import Option from "./Option";
import selectIcons from "./Icons";

const groupsHaveOptions = (options) =>
options?.[0].options ? options.some((groupOption) => groupOption.options?.length > 0) : true;

const Listbox = React.forwardRef<ListboxRefType, ListboxProps>(
(
{
id,
currentValue,
options,
visualFocusIndex,
lastOptionIndex,
multiple,
optional,
optionalItem,
searchable,
handleOptionOnClick,
},
ref
): JSX.Element => {
const colorsTheme = useTheme();
const translatedLabels = useTranslatedLabels();
let globalIndex = optional && !multiple ? 0 : -1; // index for options, starting from 0 to options.length -1
const mapOptionFunc = (option, mapIndex) => {
if (option.options) {
const groupId = `group-${mapIndex}`;
return (
option.options.length > 0 && (
<li key={`group-${mapIndex}`}>
<GroupList role="group" aria-labelledby={groupId}>
<GroupLabel role="presentation" id={groupId}>
{option.label}
</GroupLabel>
{option.options.map((singleOption) => {
globalIndex++;
return (
<Option
key={`option-${singleOption.value}`}
id={`option-${globalIndex}`}
option={singleOption}
onClick={handleOptionOnClick}
multiple={multiple}
visualFocused={visualFocusIndex === globalIndex}
isGroupedOption={true}
isLastOption={lastOptionIndex === globalIndex}
isSelected={
multiple ? currentValue.includes(singleOption.value) : currentValue === singleOption.value
}
/>
);
})}
</GroupList>
</li>
)
);
} else {
globalIndex++;
return (
<Option
key={`option-${option.value}`}
id={`option-${globalIndex}`}
option={option}
onClick={handleOptionOnClick}
multiple={multiple}
visualFocused={visualFocusIndex === globalIndex}
isLastOption={lastOptionIndex === globalIndex}
isSelected={multiple ? currentValue.includes(option.value) : currentValue === option.value}
/>
);
}
};
const Listbox = ({
id,
currentValue,
options,
visualFocusIndex,
lastOptionIndex,
multiple,
optional,
optionalItem,
searchable,
handleOptionOnClick,
styles,
}: ListboxProps): JSX.Element => {
const colorsTheme = useTheme();
const translatedLabels = useTranslatedLabels();
const listboxRef = useRef(null);

let globalIndex = optional && !multiple ? 0 : -1; // index for options, starting from 0 to options.length -1
const mapOptionFunc = (option, mapIndex) => {
if (option.options) {
const groupId = `group-${mapIndex}`;
return (
option.options.length > 0 && (
<li key={`group-${mapIndex}`}>
<GroupList role="group" aria-labelledby={groupId}>
<GroupLabel role="presentation" id={groupId}>
{option.label}
</GroupLabel>
{option.options.map((singleOption) => {
globalIndex++;
return (
<Option
key={`option-${singleOption.value}`}
id={`option-${globalIndex}`}
option={singleOption}
onClick={handleOptionOnClick}
multiple={multiple}
visualFocused={visualFocusIndex === globalIndex}
isGroupedOption={true}
isLastOption={lastOptionIndex === globalIndex}
isSelected={
multiple ? currentValue.includes(singleOption.value) : currentValue === singleOption.value
}
/>
);
})}
</GroupList>
</li>
)
);
} else {
globalIndex++;
return (
<Option
key={`option-${option.value}`}
id={`option-${globalIndex}`}
option={option}
onClick={handleOptionOnClick}
multiple={multiple}
visualFocused={visualFocusIndex === globalIndex}
isLastOption={lastOptionIndex === globalIndex}
isSelected={multiple ? currentValue.includes(option.value) : currentValue === option.value}
/>
);
}
};

useLayoutEffect(() => {
if (currentValue && !multiple) {
const listEl = listboxRef?.current;
const selectedListOptionEl = listEl?.querySelector("[aria-selected='true']");
listEl?.scrollTo?.({ top: selectedListOptionEl?.offsetTop - listEl?.clientHeight / 2 });
}
}, [currentValue, multiple]);

return (
<ThemeProvider theme={colorsTheme.select}>
<ListboxContainer
id={id}
onClick={(event) => {
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
}}
ref={ref}
role="listbox"
aria-multiselectable={multiple}
aria-orientation="vertical"
>
{searchable && (options.length === 0 || !groupsHaveOptions(options)) ? (
<OptionsSystemMessage>
<NoMatchesFoundIcon>{selectIcons.searchOff}</NoMatchesFoundIcon>
{translatedLabels.select.noMatchesErrorMessage}
</OptionsSystemMessage>
) : (
optional &&
!multiple && (
<Option
key={`option-${optionalItem.value}`}
id={`option-${0}`}
option={optionalItem}
onClick={handleOptionOnClick}
multiple={multiple}
visualFocused={visualFocusIndex === 0}
isGroupedOption={false}
isLastOption={lastOptionIndex === 0}
isSelected={multiple ? currentValue.includes(optionalItem.value) : currentValue === optionalItem.value}
/>
)
)}
{options.map(mapOptionFunc)}
</ListboxContainer>
</ThemeProvider>
);
}
);
useLayoutEffect(() => {
const visualFocusedOptionEl = listboxRef?.current?.querySelectorAll("[role='option']")[visualFocusIndex];
visualFocusedOptionEl?.scrollIntoView?.({ block: "nearest", inline: "start" });
}, [visualFocusIndex]);

return (
<ThemeProvider theme={colorsTheme.select}>
<ListboxContainer
id={id}
onClick={(event) => {
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
}}
ref={listboxRef}
role="listbox"
aria-multiselectable={multiple}
aria-orientation="vertical"
style={styles}
>
{searchable && (options.length === 0 || !groupsHaveOptions(options)) ? (
<OptionsSystemMessage>
<NoMatchesFoundIcon>{selectIcons.searchOff}</NoMatchesFoundIcon>
{translatedLabels.select.noMatchesErrorMessage}
</OptionsSystemMessage>
) : (
optional &&
!multiple && (
<Option
key={`option-${optionalItem.value}`}
id={`option-${0}`}
option={optionalItem}
onClick={handleOptionOnClick}
multiple={multiple}
visualFocused={visualFocusIndex === 0}
isGroupedOption={false}
isLastOption={lastOptionIndex === 0}
isSelected={multiple ? currentValue.includes(optionalItem.value) : currentValue === optionalItem.value}
/>
)
)}
{options.map(mapOptionFunc)}
</ListboxContainer>
</ThemeProvider>
);
};

const ListboxContainer = styled.ul`
position: absolute;
z-index: 1;
box-sizing: border-box;
max-height: 304px;
overflow-y: auto;
top: calc(100% + 4px);
left: 0;
margin: 0;
padding: 0.25rem 0;
width: 100%;
background-color: ${(props) => props.theme.listDialogBackgroundColor};
border: 1px solid ${(props) => props.theme.listDialogBorderColor};
border-radius: 0.25rem;
Expand Down