Skip to content

Commit

Permalink
Merge pull request #1306 from isaacphysics/redesign/reusable-tab-picker
Browse files Browse the repository at this point in the history
Reusable tab picker component
  • Loading branch information
axlewin authored Feb 11, 2025
2 parents 82ee33f + 24d0d6e commit b6bad3e
Show file tree
Hide file tree
Showing 6 changed files with 93 additions and 66 deletions.
36 changes: 36 additions & 0 deletions src/app/components/elements/inputs/StyledTabPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from "react";
import { ReactNode } from "react";
import { Label, Input } from "reactstrap";
import { isDefined } from "../../../services";

/**
* @typedef {Object} StyledTabPickerProps
* @property {string} id - A unique identifier for the tab picker.
* @property {boolean} [checked] - Whether the tab is checked.
* @property {React.ChangeEventHandler<HTMLInputElement>} [onInputChange] - The function to call when the tab is clicked.
* @property {ReactNode} checkboxTitle - The title of the tab.
* @property {number} [count] - The number to display on the tab.
*/

interface StyledTabPickerProps extends React.HTMLAttributes<HTMLLabelElement> {
checked?: boolean;
onInputChange?: React.ChangeEventHandler<HTMLInputElement> | undefined;
checkboxTitle: ReactNode;
count?: number;
}

/**
* A StyledTabPicker component, used to render a list of selectable tabs, each with a title and optional counter (as to indicate how many options selecting that would provide).
* This can work as either a radio button or a multi-select checkbox, depending on the functionality of onInputChange.
*
* @param {StyledTabPickerProps} props
* @returns {JSX.Element}
*/
export const StyledTabPicker = (props: StyledTabPickerProps): JSX.Element => {
const { checked, onInputChange, checkboxTitle, count, ...rest } = props;
return <Label {...rest} className="d-flex align-items-center tab-picker py-2 mb-1">
<Input type="checkbox" checked={checked ?? false} onChange={onInputChange} />
<span className="ms-3">{checkboxTitle}</span>
{isDefined(count) && <span className="badge rounded-pill ms-2">{count}</span>}
</Label>;
};
39 changes: 12 additions & 27 deletions src/app/components/elements/layout/SidebarLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { ChangeEvent, ReactNode, RefObject, useEffect, useRef, useState } from "react";
import { Col, ColProps, RowProps, Input, Label, Offcanvas, OffcanvasBody, OffcanvasHeader, Row } from "reactstrap";
import React, { ChangeEvent, RefObject, useEffect, useRef, useState } from "react";
import { Col, ColProps, RowProps, Input, Offcanvas, OffcanvasBody, OffcanvasHeader, Row } from "reactstrap";
import partition from "lodash/partition";
import classNames from "classnames";
import { AssignmentDTO, ContentSummaryDTO, IsaacConceptPageDTO, QuestionDTO } from "../../../../IsaacApiTypes";
Expand All @@ -13,6 +13,7 @@ import { getHumanContext } from "../../../services/pageContext";
import { AssignmentState } from "../../pages/MyAssignments";
import { ShowLoadingQuery } from "../../handlers/ShowLoadingQuery";
import { Spacer } from "../Spacer";
import { StyledTabPicker } from "../inputs/StyledTabPicker";

export const SidebarLayout = (props: RowProps) => {
const { className, ...rest } = props;
Expand Down Expand Up @@ -179,23 +180,7 @@ export const ConceptSidebar = (props: QuestionSidebarProps) => {
return <QuestionSidebar {...props} />;
};

interface FilterCheckboxBaseProps extends React.HTMLAttributes<HTMLLabelElement> {
id: string;
checked?: boolean;
onInputChange?: React.ChangeEventHandler<HTMLInputElement> | undefined;
filterTitle: ReactNode;
conceptFilters?: Tag[];
count?: number;
}

const FilterCheckboxBase = (props: FilterCheckboxBaseProps) => {
const { id, checked, onInputChange, filterTitle, count, ...rest } = props;
return <Label {...rest} className="d-flex align-items-center filters-checkbox py-2 mb-1">
<Input id={`problem-search-${id}`} type="checkbox" checked={checked ?? false} onChange={onInputChange} />
<span className="ms-3">{filterTitle}</span>
{isDefined(count) && <span className="badge rounded-pill ms-2">{count}</span>}
</Label>;
};

interface FilterCheckboxProps extends React.HTMLAttributes<HTMLLabelElement> {
tag: Tag;
Expand All @@ -212,17 +197,17 @@ const FilterCheckbox = (props : FilterCheckboxProps) => {
setChecked(conceptFilters.includes(tag));
}, [conceptFilters, tag]);

return <FilterCheckboxBase {...rest} id={tag.id} checked={checked}
return <StyledTabPicker {...rest} id={tag.id} checked={checked}
onInputChange={(e: ChangeEvent<HTMLInputElement>) => setConceptFilters(f => e.target.checked ? [...f, tag] : f.filter(c => c !== tag))}
filterTitle={tag.title} count={tagCounts && isDefined(tagCounts[tag.id]) ? tagCounts[tag.id] : undefined}
checkboxTitle={tag.title} count={tagCounts && isDefined(tagCounts[tag.id]) ? tagCounts[tag.id] : undefined}
/>;
};

const AllFiltersCheckbox = (props: Omit<FilterCheckboxProps, "tag">) => {
const { conceptFilters, setConceptFilters, tagCounts, ...rest } = props;
const [previousFilters, setPreviousFilters] = useState<Tag[]>([]);
return <FilterCheckboxBase {...rest}
id="all" checked={!conceptFilters.length} filterTitle="All" count={tagCounts && Object.values(tagCounts).reduce((a, b) => a + b, 0)}
return <StyledTabPicker {...rest}
id="all" checked={!conceptFilters.length} checkboxTitle="All" count={tagCounts && Object.values(tagCounts).reduce((a, b) => a + b, 0)}
onInputChange={(e) => {
if (e.target.checked) {
setPreviousFilters(conceptFilters);
Expand Down Expand Up @@ -312,8 +297,8 @@ interface AssignmentStatusCheckboxProps extends React.HTMLAttributes<HTMLLabelEl

const AssignmentStatusCheckbox = (props: AssignmentStatusCheckboxProps) => {
const {status, statusFilter, setStatusFilter, count, ...rest} = props;
return <FilterCheckboxBase
id={status ?? ""} filterTitle={status}
return <StyledTabPicker
id={status ?? ""} checkboxTitle={status}
onInputChange={() => !statusFilter.includes(status) ? setStatusFilter(c => [...c.filter(s => s !== AssignmentState.ALL), status]) : setStatusFilter(c => c.filter(s => s !== status))}
checked={statusFilter.includes(status)}
count={count} {...rest}
Expand All @@ -323,8 +308,8 @@ const AssignmentStatusCheckbox = (props: AssignmentStatusCheckboxProps) => {
const AssignmentStatusAllCheckbox = (props: Omit<AssignmentStatusCheckboxProps, "status">) => {
const { statusFilter, setStatusFilter, count, ...rest } = props;
const [previousFilters, setPreviousFilters] = useState<AssignmentState[]>([]);
return <FilterCheckboxBase
id="all" filterTitle="All"
return <StyledTabPicker
id="all" checkboxTitle="All"
onInputChange={(e) => {
if (e.target.checked) {
setPreviousFilters(statusFilter);
Expand Down Expand Up @@ -505,4 +490,4 @@ export const MyAccountSidebar = (props: SidebarProps) => {
return <ContentSidebar buttonTitle="Account settings" {...props}>
{props.children}
</ContentSidebar>;
};
};
10 changes: 4 additions & 6 deletions src/app/components/pages/MyAccount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ import {
allRequiredInformationIsPresent,
history,
ifKeyIsEnter,
isAda,
isDefined,
isDobOldEnoughForSite,
isFirstLoginInPersistence,
Expand All @@ -73,6 +72,7 @@ import {UserProfile} from '../elements/panels/UserProfile';
import {UserContent} from '../elements/panels/UserContent';
import {ExigentAlert} from "../elements/ExigentAlert";
import {MainContent, MyAccountSidebar, SidebarLayout} from '../elements/layout/SidebarLayout';
import { StyledTabPicker } from '../elements/inputs/StyledTabPicker';

const UserMFA = lazy(() => import("../elements/panels/UserMFA"));

Expand Down Expand Up @@ -332,12 +332,10 @@ const AccountPageComponent = ({user, getChosenUserAuthSettings, error, userAuthS
<div className="section-divider mt-0"/>
<h5>Account settings</h5>
{ACCOUNT_TABS.filter(tab => !tab.hidden && !(editingOtherUser && tab.hiddenIfEditingOtherUser)).map(({tab, title}) =>
<NavLink
key={tab} tabIndex={0} className={classnames("sidebar-tab", {"active-tab": activeTab === tab})}
<StyledTabPicker
key={tab} id={title} tabIndex={0} checkboxTitle={title} checked={activeTab === tab}
onClick={() => setActiveTab(tab)} onKeyDown={ifKeyIsEnter(() => setActiveTab(tab))}
>
{title}
</NavLink>
/>
)}
</MyAccountSidebar>
<MainContent className="w-lg-50">
Expand Down
37 changes: 37 additions & 0 deletions src/scss/common/elements.scss
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,40 @@ iframe.email-html {
display: block;
}
}

.tab-picker {
cursor: pointer;
border-left: solid 2px transparent;

.badge.rounded-pill {
background-color: white;
color: var(--bs-body-color-rgb);
}

background-size: 205% 100%;
background-image: linear-gradient(90deg, var(--subject-color-100) 50%, transparent 50%);
background-position: 100% 0%;
transition: background-position 0.3s ease-in-out;

&:has(> input:checked) {
border-left: solid 2px var(--sidebar-tab-active);
background-position: 0% 0%;

.badge.rounded-pill {
background-color: var(--sidebar-tab-active);
color: white;
}
}

&:hover:not(:has(> input:checked)) {
border-left: solid 2px var(--sidebar-tab-hover);
}

&:focus:not(:focus-visible) {
outline: none;
}

> input {
display: none;
}
}
4 changes: 4 additions & 0 deletions src/scss/phy/color-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
// NAVIGATION
--nav-primary: #{map.get($subject-colors, 500)};
--nav-secondary: #{map.get($subject-colors, 400)};
--sidebar-tab-active: #{map.get($subject-colors, 500)};
--sidebar-tab-hover: #{map.get($subject-colors, 300)};

// ICONS
--icon-primary: #{map.get($subject-colors, 500)};
Expand Down Expand Up @@ -71,6 +73,8 @@
--buttons-light-hover-prefix: #{$color-neutral-200};

--nav-primary: #{$color-brand-500};
--sidebar-tab-active: #{$color-brand-500};
--sidebar-tab-hover: #{$color-neutral-300};

--icon-primary: #{$color-brand-500};

Expand Down
33 changes: 0 additions & 33 deletions src/scss/phy/sidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -51,39 +51,6 @@
color: $color-neutral-500;
font-size: 15px;
}

.filters-checkbox {
cursor: pointer;
border-left: solid 2px transparent;

.badge.rounded-pill {
background-color: white;
color: var(--bs-body-color-rgb);
}

background-size: 205% 100%;
background-image: linear-gradient(90deg, var(--subject-color-100) 50%, transparent 50%);
background-position: 100% 0%;
transition: background-position 0.3s ease-in-out;

&:has(> input:checked) {
border-left: solid 2px var(--subject-color-500);
background-position: 0% 0%;

.badge.rounded-pill {
background-color: var(--subject-color-500);
color: white;
}
}

&:hover:not(:has(> input:checked)) {
border-left: solid 2px var(--subject-color-300);
}

> input {
display: none;
}
}
}

#content-sidebar-offcanvas {
Expand Down

0 comments on commit b6bad3e

Please sign in to comment.