Skip to content

Commit

Permalink
[web] Adds a core/OptionPicker component
Browse files Browse the repository at this point in the history
Which helps rendering a set of options with a tile look&feel.

Not using PatternFly/Tile instead because it looks like it's going to be
deprecated [1] and the PatternFly/Card#isSelectable component is a bit
complex for this first use case.

[1] patternfly/patternfly-react#8542
  • Loading branch information
dgdavid committed Feb 16, 2024
1 parent 19245ee commit 8d432fb
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 36 deletions.
26 changes: 26 additions & 0 deletions web/src/assets/styles/blocks.scss
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,32 @@ ul[data-type="agama/list"][role="grid"] {
}
}

[data-type="agama/options-picker"] {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacer-smaller);

[role="option"] {
cursor: pointer;
border: 1px solid var(--color-gray);
padding: var(--spacer-small);
border-block-end-width: 4px;

&[aria-selected="true"] {
background: var(--color-gray-light);
border-block-end-color: var(--color-primary);
}

>:first-child {
margin-block-end: var(--spacer-small);
}

>:last-child {
font-size: var(--fs-small);
}
}
}

[role="dialog"] {
section:not([class^="pf-c"]) {
> svg:first-child {
Expand Down
71 changes: 71 additions & 0 deletions web/src/components/core/OptionsPicker.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (c) [2024] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of version 2 of the GNU General Public License as published
* by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

import React from "react";

/**
* Wrapper for OptionsPicker options
* @component
*
* @param {object} props
* @param {string} [props.title] - Text to be used as option title
* @param {string} [props.body] - Text to be used as option body
* @param {boolean} [props.isSelected=false] - Whether the option should be set as select of not
* @param {object} [props.props] - Other props sent to div#option node
*/
const Option = ({ title, body, isSelected = false, ...props }) => {
return (
<div
{...props}
role="option"
aria-selected={isSelected}
>
<div><b>{title}</b></div>
<div>{body}</div>
</div>
);
};

/**
* Helper component to build rich options picker
* @component
*
* @param {object} props
* @param {string} [props.ariaLabel] - Text to be used as accessible label
* @param {Array<Option>} props.children - A collection of Option
* @param {object} [props.props] - Other props sent to div#listbox node
*/
const OptionsPicker = ({ "aria-label": ariaLabel, children, ...props }) => {
return (
<div
{...props}
role="listbox"
data-type="agama/options-picker"
aria-label={ariaLabel}
>
{children}
</div>
);
};

OptionsPicker.Option = Option;

export default OptionsPicker;
75 changes: 75 additions & 0 deletions web/src/components/core/OptionsPicker.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright (c) [2024] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of version 2 of the GNU General Public License as published
* by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

import React from "react";
import { screen } from "@testing-library/react";
import { plainRender } from "~/test-utils";
import { OptionsPicker } from "~/components/core";

describe("OptionsPicker", () => {
it("renders a node with listbox role", () => {
plainRender(<OptionsPicker />);
screen.getByRole("listbox");
});
});

describe("OptionsPicker.Option", () => {
it("renders a node with option role", () => {
plainRender(<OptionsPicker.Option />);
screen.getByRole("option");
});

it("renders given title", () => {
plainRender(<OptionsPicker.Option title="Custom" />);
screen.getByRole("option", { name: "Custom" });
});

it("renders given body", () => {
plainRender(<OptionsPicker.Option body="More freedom for user" />);
screen.getByRole("option", { name: "More freedom for user" });
});

it("triggers given onClick callback when user clicks on it", async () => {
const onClick = jest.fn();
const { user } = plainRender(<OptionsPicker.Option title="Custom" onClick={onClick} />);
const option = screen.getByRole("option", { name: "Custom" });
await user.click(option);
expect(onClick).toHaveBeenCalled();
});

it("sets as selected if isSelected is given", () => {
plainRender(<OptionsPicker.Option isSelected />);
const option = screen.getByRole("option");
expect(option).toHaveAttribute("aria-selected", "true");
});

it("sets as not selected if isSelected is not given", () => {
plainRender(<OptionsPicker.Option />);
const option = screen.getByRole("option");
expect(option).toHaveAttribute("aria-selected", "false");
});

it("sets as not selected if isSelected=false", () => {
plainRender(<OptionsPicker.Option isSelected={false} />);
const option = screen.getByRole("option");
expect(option).toHaveAttribute("aria-selected", "false");
});
});
1 change: 1 addition & 0 deletions web/src/components/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,4 @@ export { default as NumericTextInput } from "./NumericTextInput";
export { default as PasswordInput } from "./PasswordInput";
export { default as DevelopmentInfo } from "./DevelopmentInfo";
export { default as Selector } from "./Selector";
export { default as OptionsPicker } from "./OptionsPicker";
58 changes: 22 additions & 36 deletions web/src/components/storage/ProposalSpacePolicySection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@
*/

import React, { useEffect } from "react";
import { FormSelect, FormSelectOption, Radio } from "@patternfly/react-core";
import { FormSelect, FormSelectOption } from "@patternfly/react-core";

import { _, n_, N_ } from "~/i18n";
import { deviceSize } from '~/components/storage/utils';
import { If, Section, SectionSkeleton } from "~/components/core";
import { If, OptionsPicker, Section, SectionSkeleton } from "~/components/core";
import { noop, useLocalStorage } from "~/utils";
import { sprintf } from "sprintf-js";
import { Table, Thead, Tr, Th, Tbody, Td, TreeRowWrapper } from '@patternfly/react-table';
Expand Down Expand Up @@ -382,46 +382,28 @@ const SpaceActionsTable = ({ settings, onChange = noop }) => {
};

/**
* Space policy selector.
* Widget to allow user picking desired policy to make space
* @component
*
* @param {object} props
* @param {SpacePolicy} props.currentPolicy
* @param {(policy: string) => void} [props.onChange]
*/
const SpacePolicySelector = ({ currentPolicy, onChange = noop }) => {
const SpacePolicyPicker = ({ currentPolicy, onChange = noop }) => {
return (
<>
<p>
{_("Indicate how to make free space in the selected disks for allocating the file systems:")}
</p>

<div>
<div className="split radio-group">
{SPACE_POLICIES.map((policy) => {
const isChecked = policy.name === currentPolicy.name;

return (
<Radio
id={`space-policy-option-${policy.name}`}
key={`space-policy-${policy.name}`}
// eslint-disable-next-line agama-i18n/string-literals
label={_(policy.label)}
value={policy.name}
name="space-policies"
className={isChecked && "selected"}
isChecked={isChecked}
onChange={() => onChange(policy.name)}
/>
);
})}
</div>

<div aria-live="polite" className="highlighted-live-region">
{currentPolicy.description}
</div>
</div>
</>
<OptionsPicker>
{SPACE_POLICIES.map((policy) => {
return (
<OptionsPicker.Option
key={policy.name}
title={policy.label}
body={policy.description}
onClick={() => onChange(policy.name)}
isSelected={currentPolicy?.name === policy.name}
/>
);
})}
</OptionsPicker>
);
};

Expand Down Expand Up @@ -453,12 +435,16 @@ export default function ProposalSpacePolicySection({

return (
<Section title={_("Find Space")} className="flex-stack">

<If
condition={isLoading && settings.spacePolicy === undefined}
then={<SectionSkeleton numRows={4} />}
else={
<>
<SpacePolicySelector currentPolicy={currentPolicy} onChange={changeSpacePolicy} />
<p>
{_("Indicate how to make free space in the selected disks for allocating the file systems:")}
</p>
<SpacePolicyPicker currentPolicy={currentPolicy} onChange={changeSpacePolicy} />
<If
condition={settings.installationDevices?.length > 0}
then={<SpaceActionsTable settings={settings} onChange={changeSpaceActions} />}
Expand Down

0 comments on commit 8d432fb

Please sign in to comment.