Skip to content

Commit

Permalink
[web] Adds a core/Selector component (#1012)
Browse files Browse the repository at this point in the history
Adds a core/Selector component and uses it to render suitable selectors
across Agama codebase instead of defining almost the same code
repeatedly.

Note that, while it was originally intended to have a fully accessible
selector, time and priorities constraints (among other reasons) forced
to _just centralize_ the repetitive code for rendering selectors with an
small approach change.

Once priorities permit, work on a better, fully accessible selector will
be resumed.

---

Adapted selectors:

* L10n: Language, Keymap, Timezone
* Storage: Disk selectors, Space policy
* Product: product selection

---

Related to
#786 (comment)
  • Loading branch information
dgdavid authored Feb 15, 2024
2 parents 9b6654b + a7a88a5 commit be9bb78
Show file tree
Hide file tree
Showing 23 changed files with 961 additions and 486 deletions.
6 changes: 6 additions & 0 deletions web/package/cockpit-agama.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
-------------------------------------------------------------------
Wed Feb 14 12:42:32 UTC 2024 - David Diaz <dgonzalez@suse.com>

- UI: change look&feel and internals of Agama selectors
(gh#openSUSE/agama#1012).

-------------------------------------------------------------------
Mon Feb 12 11:53:29 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>

Expand Down
115 changes: 64 additions & 51 deletions web/src/assets/styles/blocks.scss
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ ul[data-type="agama/list"] {
border-top: 0;
}

&:not(:last-child) {
border-bottom-width: 1px;
}

> div {
margin-block-end: var(--spacer-smaller);
}
Expand All @@ -259,21 +263,18 @@ ul[data-type="agama/list"] {
// FIXME: see if it's semantically correct to mark an li as aria-selected when
// not belongs to a listbox or grid list ul.
li[aria-selected] {
border-color: var(--color-primary);
box-shadow: 0 2px 5px 0 var(--color-gray-dark);
background: var(--color-primary);
color: white;
background: var(--color-gray-dark);
font-weight: 700;

svg {
fill: white;
&:not(:last-child) {
border-bottom-color: white;
}
}
}

// These attributes together means that UI is rendering a selector
ul[data-type="agama/list"][role="listbox"] {
li[role="option"] {
ul[data-type="agama/list"][role="grid"] {
li[role="row"] {
cursor: pointer;

&:first-child {
Expand All @@ -292,72 +293,84 @@ ul[data-type="agama/list"][role="listbox"] {
&:not([aria-selected]) {
background: var(--color-gray-dark);
}

&:not(:last-child) {
border-bottom-color: white;
}
}

div[role="gridcell"] {
display: flex;
align-items: center;
gap: var(--spacer-normal);

& > input {
--size: var(--fs-h2);
block-size: var(--size);
inline-size: var(--size);
}

& > div {
flex: 1;
}
}
}
}

// Each kind of list/selector has its way of laying out their items
ul[data-of="agama/storage-devices"] {
li {
display: grid;
gap: var(--spacer-smaller);
grid-template-columns: 1fr 2fr 2fr;
grid-template-areas: "type-and-size drive-info drive-content";
[data-items-type="agama/storage-devices"] {
display: grid;
gap: var(--spacer-smaller);
grid-template-columns: 1fr 2fr 2fr;
grid-template-areas: "type-and-size drive-info drive-content";

svg {
vertical-align: inherit;
}
svg {
vertical-align: inherit;
}

> div {
margin-block-end: 0;
}
> div {
margin-block-end: 0;
}

> :first-child {
align-self: center;
text-align: center;
justify-self: start;
}
> :first-child {
align-self: center;
text-align: center;
justify-self: start;
}
}

ul[data-of="agama/space-policies"] {
[data-items-type="agama/space-policies"] {
// It works with the default styling
}

ul[data-of="agama/locales"] {
li {
display: grid;
grid-template-columns: 1fr 2fr;
[data-items-type="agama/locales"] {
display: grid;
grid-template-columns: 1fr 2fr;

> :last-child {
grid-column: 1 / -1;
font-size: var(--fs-small);
}
> :last-child {
grid-column: 1 / -1;
font-size: var(--fs-small);
}
}

ul[data-of="agama/keymaps"] {
li {
> :last-child {
font-size: var(--fs-small);
}
[data-items-type="agama/keymaps"] {
> :last-child {
font-size: var(--fs-small);
}
}

ul[data-of="agama/timezones"] {
li {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
[data-items-type="agama/timezones"] {
display: grid;
grid-template-columns: 2fr 1fr 1fr;

> :last-child {
grid-column: 1 / -1;
font-size: 80%;
}
> :last-child {
grid-column: 1 / -1;
font-size: 80%;
}

> :nth-child(3) {
color: var(--color-gray-dimmed);
text-align: end;
}
> :nth-child(3) {
color: var(--color-gray-dimmed);
text-align: end;
}
}

Expand Down
124 changes: 124 additions & 0 deletions web/src/components/core/Selector.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* 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';
import { noop } from '~/utils';

/**
* @callback onSelectionChangeCallback
* @param {Array<string>} selection - ids of selected options
*/

/**
* Agama component for building a selector
*
* @example <caption>Usage example</caption>
* const options = [
* { id: "es_ES", country: "Spain", label: "Spanish" },
* { id: "cs_CZ", country: "Czechia", label: "Czech" },
* { id: "de_DE", country: "Germany", label: "German" },
* { id: "en_GB", country: "United Kingdom", label: "English" }
* ];
*
* const selectedIds = ["es_ES", "en_GB"];
*
* const renderFn = ({ label, country }) => <div>{label} - {country}</div>;
*
* return (
* <Selector
* isMultiple
* aria-label="Available locales"
* selectedIds={selectedIds}
* options={options}
* renderOption={renderFn}
* onSelectionChange={(selection) => changePreferredLocales(selection)}
* />
* );
*
* @param {object} props - component props
* @param {string} [props.id] - Id attribute for selector.
* @param {boolean} [props.isMultiple=false] - Whether the selector should allow multiple selection.
* @param {Array<object>} props.options=[] - Item objects to build options.
* @param {function} props.renderOption=noop - Function used for rendering options.
* @param {string} [props.optionIdKey="id"] - Key used for retrieve options id.
* @param {Array<*>} [props.selectedIds=[]] - Identifiers for selected options.
* @param {onSelectionChangeCallback} [props.onSelectionChange=noop] - Callback to be called when the selection changes.
* @param {object} [props.props] - Other props sent to the internal selector <ul> component
*/
const Selector = ({
id = crypto.randomUUID(),
isMultiple = false,
options = [],
renderOption = noop,
optionIdKey = "id",
selectedIds = [],
onSelectionChange = noop,
...props
}) => {
const onOptionClick = (optionId) => {
const alreadySelected = selectedIds.includes(optionId);

if (!isMultiple) {
!alreadySelected && onSelectionChange([optionId]);
return;
}

if (alreadySelected) {
onSelectionChange(selectedIds.filter((id) => id !== optionId));
} else {
onSelectionChange([...selectedIds, optionId]);
}
};

return (
<ul { ...props } id={id} data-type="agama/list" role="grid">
{ options.map(option => {
const optionId = option[optionIdKey];
const optionHtmlId = `${id}-option-${optionId}`;
const isSelected = selectedIds.includes(optionId);
const onClick = () => onOptionClick(optionId);

return (
<li
key={optionId}
id={optionHtmlId}
role="row"
onClick={onClick}
aria-selected={isSelected || undefined}
>
<div role="gridcell">
<input
type={isMultiple ? "checkbox" : "radio"}
checked={isSelected}
onChange={onClick}
aria-labelledby={optionHtmlId}
/>
{ renderOption(option) }
</div>
</li>
);
})}
</ul>
);
};

export default Selector;
Loading

0 comments on commit be9bb78

Please sign in to comment.