Skip to content

Commit

Permalink
web: Add initial version of a segmented control pattern
Browse files Browse the repository at this point in the history
Initially based on props only, but most probably it will evolve to
provide subcomponents to make possible to build more detailed options by
just writing JSX.

It has been added to be used in the new device selection dialog that
will land in the Storage page and after discarding plain HTML input
radios and tabs components. The radios were discarded because target
actions are not actually form options but rather "form/mode pickers"
which have an immediate effect on the user interface after selecting
one. The tabs for avoiding mislead the user that they are navigating
between these options. In other words, to ensure users are aware of the
mutually exclusive settings they are in front of.

It is inspired by the segmented control pattern. To read more have a
look to below links

* https://primer.style/components/segmented-control
* https://medium.com/tap-to-dismiss/a-better-segmented-control-9e662de2ef57
  • Loading branch information
dgdavid authored and joseivanlopez committed Mar 25, 2024
1 parent 4e3e9c0 commit 1dc4704
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 0 deletions.
38 changes: 38 additions & 0 deletions web/src/assets/styles/blocks.scss
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,44 @@ ul[data-type="agama/list"][role="grid"] {
}
}

[data-type="agama/segmented-control"] {
ul {
display: inline-flex;
gap: var(--spacer-smaller);
list-style-type: none;
border-radius: var(--spacer-smaller);
align-items: center;
margin-inline: 0;

li {
margin: 0;

&:not(:last-child) {
padding-inline-end: var(--spacer-smaller);
border-inline-end: 1px solid var(--color-gray-dark);
}

button {
padding: var(--spacer-smaller) var(--spacer-small);
border: 1px solid var(--color-gray-darker);
border-radius: var(--spacer-smaller);
background: white; // var(--color-gray);

&[aria-current="true"] {
background: var(--color-primary);
color: white;
font-weight: bold;
font-size: var(--fs-normal);
}

&:not([aria-current="true"]):hover {
background: var(--color-gray);
}
}
}
}
}

table[data-type="agama/tree-table"] {
th:first-child {
block-size: fit-content;
Expand Down
66 changes: 66 additions & 0 deletions web/src/components/core/SegmentedControl.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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.
*/

// @ts-check

import React from "react";

const defaultRenderLabel = (option) => option?.label;

/**
* Renders given options as a segmented control
* @component
*
* @param {object} props
* @param {object[]} props.options=[] - A collection of object.
* @param {string} [props.optionIdKey="id"] - The key to look for the option id.
* @param {(object) => React.ReactElement} [props.renderLabel=(object) => string] - The method for rendering the option label.
* @param {(object) => object} [props.onClick] - The callback triggered when user clicks an option.
* @param {object} [props.selected] - The selected option
*/
export default function SegmentedControl({
options = [],
optionIdKey = "id",
renderLabel = defaultRenderLabel,
onClick = () => {},
selected,
}) {
return (
<div data-type="agama/segmented-control">
<ul>
{ options.map((option, idx) => {
const optionId = option[optionIdKey];

return (
<li key={optionId || idx}>
<button
aria-current={option === selected}
onClick={() => onClick(option)}
>
{renderLabel(option)}
</button>
</li>
);
})}
</ul>
</div>
);
}
70 changes: 70 additions & 0 deletions web/src/components/core/SegmentedControl.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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 { SegmentedControl } from "~/components/core";

const selectOption = { label: "Select", longLabel: "Select item", description: "Select an existing item" };
const createOption = { label: "Create", longLabel: "Create item", description: "Create a new item" };
const options = [
selectOption, createOption
];

describe("SegmentedControl", () => {
it("renders each given option as a button", () => {
plainRender(<SegmentedControl options={options} />);
screen.getByRole("button", { name: "Select" });
screen.getByRole("button", { name: "Create" });
});

it("uses renderLabel for rendering the button text", () => {
const onClick = jest.fn();

Check warning on line 41 in web/src/components/core/SegmentedControl.test.jsx

View workflow job for this annotation

GitHub Actions / frontend_build (18.x)

'onClick' is assigned a value but never used

Check warning on line 41 in web/src/components/core/SegmentedControl.test.jsx

View workflow job for this annotation

GitHub Actions / frontend_build (18.x)

'onClick' is assigned a value but never used
const { user } = plainRender(

Check warning on line 42 in web/src/components/core/SegmentedControl.test.jsx

View workflow job for this annotation

GitHub Actions / frontend_build (18.x)

'user' is assigned a value but never used

Check warning on line 42 in web/src/components/core/SegmentedControl.test.jsx

View workflow job for this annotation

GitHub Actions / frontend_build (18.x)

'user' is assigned a value but never used
<SegmentedControl options={options} renderLabel={(option) => option.longLabel } />

Check failure on line 43 in web/src/components/core/SegmentedControl.test.jsx

View workflow job for this annotation

GitHub Actions / frontend_build (18.x)

Multiple spaces found before '/'

Check failure on line 43 in web/src/components/core/SegmentedControl.test.jsx

View workflow job for this annotation

GitHub Actions / frontend_build (18.x)

Multiple spaces found before '/'
);

const buttonByLabel = screen.queryByRole("button", { name: "Select" });
expect(buttonByLabel).toBeNull();
screen.getByRole("button", { name: "Select item" });
});

it("sets proper aria-current value for each button", () => {
plainRender(<SegmentedControl options={options} selected={createOption} />);
const selectButton = screen.getByRole("button", { name: "Select" });
const createButton = screen.getByRole("button", { name: "Create" });
expect(selectButton).toHaveAttribute("aria-current", "false");
expect(createButton).toHaveAttribute("aria-current", "true");
});

it("triggers given onClick callback when user clicks an option", async () => {
const onClick = jest.fn();
const { user } = plainRender(
<SegmentedControl options={options} selected={createOption} onClick={onClick} />
);
const selectButton = screen.getByRole("button", { name: "Select" });

await user.click(selectButton);

expect(onClick).toHaveBeenCalledWith(selectOption);
});
});
1 change: 1 addition & 0 deletions web/src/components/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,4 @@ export { default as OptionsPicker } from "./OptionsPicker";
export { default as Reminder } from "./Reminder";
export { default as Tag } from "./Tag";
export { default as TreeTable } from "./TreeTable";
export { default as SegmentedControl } from "./SegmentedControl";

0 comments on commit 1dc4704

Please sign in to comment.