manual improvement 1
This commit is contained in:
@@ -2,7 +2,7 @@ import { FunctionComponent } from 'react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import AppButton, { AppButtonProps } from './AppButton';
|
||||
import DefaultIcon from '@mui/icons-material/MoreHoriz';
|
||||
import { Ellipsis as DefaultIcon } from 'lucide-react';
|
||||
import { randomText, capitalize } from '@/utils';
|
||||
|
||||
/**
|
||||
@@ -130,7 +130,8 @@ describe('<AppButton/> component', () => {
|
||||
let text = 'button with start icon';
|
||||
render(<ComponentToTest text={text} startIcon="default" />);
|
||||
let button = screen.getByText(text);
|
||||
let icon = within(button).getByTestId('MoreHorizRoundedIcon'); //Note: this is valid only when "default" icon is <MoreHorizRoundedIcon />
|
||||
// The registry is Lucide now — identify the glyph by AppIcon's own data-icon, not a MUI testid.
|
||||
let icon = button.querySelector('[data-icon="default"]') as HTMLElement;
|
||||
expect(icon).toBeDefined();
|
||||
let span = icon.closest('span');
|
||||
expect(span).toHaveClass('MuiButton-startIcon');
|
||||
@@ -140,7 +141,8 @@ describe('<AppButton/> component', () => {
|
||||
let text = 'button with end icon as string';
|
||||
render(<ComponentToTest text={text} endIcon="default" />);
|
||||
let button = screen.getByText(text);
|
||||
let icon = within(button).getByTestId('MoreHorizRoundedIcon'); //Note: this is valid only when "default" icon is <MoreHorizRoundedIcon />
|
||||
// The registry is Lucide now — identify the glyph by AppIcon's own data-icon, not a MUI testid.
|
||||
let icon = button.querySelector('[data-icon="default"]') as HTMLElement;
|
||||
expect(icon).toBeDefined();
|
||||
let span = icon.closest('span');
|
||||
expect(span).toHaveClass('MuiButton-endIcon');
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ElementType, FunctionComponent, ReactNode, useMemo } from 'react';
|
||||
import Button, { ButtonProps } from '@mui/material/Button';
|
||||
import AppIcon from '../AppIcon';
|
||||
import AppLink from '../AppLink';
|
||||
import { APP_BUTTON_VARIANT } from '../../config';
|
||||
import { APP_BUTTON_ICON_SIZE, APP_BUTTON_VARIANT } from '../../config';
|
||||
|
||||
const MUI_BUTTON_COLORS = ['inherit', 'primary', 'secondary', 'success', 'error', 'info', 'warning'];
|
||||
|
||||
@@ -44,13 +44,26 @@ const AppButton: FunctionComponent<AppButtonProps> = ({
|
||||
variant = APP_BUTTON_VARIANT,
|
||||
...restOfProps
|
||||
}) => {
|
||||
// Sized explicitly: MUI's own `fontSize: 20` rule on the icon slot only bites on an SvgIcon that
|
||||
// sizes off 1em — the Lucide registry sizes off real width/height attributes and would otherwise
|
||||
// render at the full 24px default inside every button.
|
||||
const iconStart: ReactNode = useMemo(
|
||||
() => (!startIcon ? undefined : typeof startIcon === 'string' ? <AppIcon icon={String(startIcon)} /> : startIcon),
|
||||
() =>
|
||||
!startIcon ? undefined : typeof startIcon === 'string' ? (
|
||||
<AppIcon icon={String(startIcon)} size={APP_BUTTON_ICON_SIZE} />
|
||||
) : (
|
||||
startIcon
|
||||
),
|
||||
[startIcon]
|
||||
);
|
||||
|
||||
const iconEnd: ReactNode = useMemo(
|
||||
() => (!endIcon ? undefined : typeof endIcon === 'string' ? <AppIcon icon={String(endIcon)} /> : endIcon),
|
||||
() =>
|
||||
!endIcon ? undefined : typeof endIcon === 'string' ? (
|
||||
<AppIcon icon={String(endIcon)} size={APP_BUTTON_ICON_SIZE} />
|
||||
) : (
|
||||
endIcon
|
||||
),
|
||||
[endIcon]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import AppIcon from './AppIcon';
|
||||
import { APP_ICON_SIZE } from '../../config';
|
||||
import { APP_ICON_SIZE, APP_ICON_STROKE_WIDTH } from '../../config';
|
||||
import { randomColor, randomText } from '@/utils';
|
||||
import { ICONS } from './config';
|
||||
import { DIRECTIONAL_ICONS, ICONS } from './config';
|
||||
|
||||
const ComponentToTest = AppIcon;
|
||||
|
||||
@@ -16,22 +16,22 @@ describe('<AppIcon/> component', () => {
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toBeDefined();
|
||||
expect(svg).toHaveAttribute('data-icon', 'default');
|
||||
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
|
||||
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
|
||||
// Size is actually driven by fontSize (MUI SvgIcon's `1em` sizing beats
|
||||
// width/height attributes) — never assert a `size` DOM attribute, it's invalid.
|
||||
expect(svg).toHaveStyle(`font-size: ${APP_ICON_SIZE}px`);
|
||||
// Lucide sizes off real width/height attributes — no 1em/fontSize indirection to work around.
|
||||
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE));
|
||||
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE));
|
||||
expect(svg).toHaveAttribute('stroke-width', String(APP_ICON_STROKE_WIDTH));
|
||||
});
|
||||
|
||||
it('supports .color property', () => {
|
||||
const testId = randomText(8);
|
||||
const color = randomColor(); // Note: 'rgb(255, 128, 0)' format is used by react-icons npm, so tests may fail
|
||||
const color = randomColor();
|
||||
render(<ComponentToTest data-testid={testId} color={color} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toHaveAttribute('data-icon', 'default');
|
||||
// expect(svg).toHaveAttribute('color', color); // TODO: Looks like MUI Icons exclude .color property from <svg> rendering
|
||||
expect(svg).toHaveStyle(`color: ${color}`);
|
||||
expect(svg).toHaveAttribute('fill', 'currentColor'); // .fill must be 'currentColor' when .color property is set
|
||||
// The whole family is stroked, never filled — a `fill` would paint the glyph solid.
|
||||
expect(svg).toHaveAttribute('stroke', color);
|
||||
expect(svg).toHaveAttribute('fill', 'none');
|
||||
});
|
||||
|
||||
it('supports .icon property', () => {
|
||||
@@ -52,9 +52,6 @@ describe('<AppIcon/> component', () => {
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toHaveAttribute('height', String(size));
|
||||
expect(svg).toHaveAttribute('width', String(size));
|
||||
// The bug this regression-guards: MUI SvgIcon's own CSS sets width/height:1em,
|
||||
// which beats plain width/height attributes — only fontSize actually resizes it.
|
||||
expect(svg).toHaveStyle(`font-size: ${size}px`);
|
||||
});
|
||||
|
||||
it('supports .title property', () => {
|
||||
@@ -65,4 +62,15 @@ describe('<AppIcon/> component', () => {
|
||||
expect(svg).toBeDefined();
|
||||
expect(svg).toHaveAttribute('title', title);
|
||||
});
|
||||
|
||||
it('marks only the registered directional icons for the RTL mirror', () => {
|
||||
for (const icon of Object.keys(ICONS) as Array<keyof typeof ICONS>) {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} icon={icon} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
// The single globals.css rule keys off this attribute — a name drifting out of
|
||||
// DIRECTIONAL_ICONS silently stops mirroring under RTL, which is invisible in review.
|
||||
expect(svg.hasAttribute('data-icon-directional')).toBe(DIRECTIONAL_ICONS.has(icon));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { ComponentType, FunctionComponent, SVGAttributes } from 'react';
|
||||
import { APP_ICON_SIZE } from '../../config';
|
||||
import { ComponentType, CSSProperties, FunctionComponent } from 'react';
|
||||
import { APP_ICON_SIZE, APP_ICON_STROKE_WIDTH } from '../../config';
|
||||
import { IconName, ICONS, DIRECTIONAL_ICONS } from './config';
|
||||
import { IconProps } from './utils';
|
||||
|
||||
/**
|
||||
* Props of the AppIcon component, also can be used for SVG icons
|
||||
*/
|
||||
export interface Props extends SVGAttributes<SVGElement> {
|
||||
color?: string;
|
||||
export interface Props extends IconProps {
|
||||
icon?: IconName | string;
|
||||
size?: string | number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,18 +16,20 @@ export interface Props extends SVGAttributes<SVGElement> {
|
||||
* @param {string} [color] - color of the icon as a CSS color value
|
||||
* @param {string} [icon] - name of the Icon to render
|
||||
* @param {string} [title] - title/hint to show when the cursor hovers the icon
|
||||
* @param {string | number} [size] - size of the icon, default is ICON_SIZE
|
||||
* @param {string | number} [size] - size of the icon, default is APP_ICON_SIZE
|
||||
* @param {string | number} [strokeWidth] - Lucide stroke weight, default is APP_ICON_STROKE_WIDTH
|
||||
*/
|
||||
const AppIcon: FunctionComponent<Props> = ({
|
||||
color,
|
||||
icon = 'default',
|
||||
size = APP_ICON_SIZE,
|
||||
strokeWidth = APP_ICON_STROKE_WIDTH,
|
||||
style,
|
||||
...restOfProps
|
||||
}) => {
|
||||
const iconName = (icon || 'default').trim().toLowerCase() as IconName;
|
||||
|
||||
let ComponentToRender: ComponentType = ICONS[iconName];
|
||||
let ComponentToRender: ComponentType<IconProps> = ICONS[iconName];
|
||||
if (!ComponentToRender) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(`AppIcon: icon "${iconName}" is not found!`);
|
||||
@@ -37,29 +37,30 @@ const AppIcon: FunctionComponent<Props> = ({
|
||||
ComponentToRender = ICONS.default;
|
||||
}
|
||||
|
||||
const sizeValue = typeof size === 'number' ? `${size}px` : size;
|
||||
|
||||
// MUI's SvgIcon sets width/height:1em via its own class, which beats plain
|
||||
// width/height attributes — driving size through fontSize (the basis for 1em)
|
||||
// is what actually resizes MUI-registry icons. Custom raw <svg> icons (the
|
||||
// brand mark) aren't MUI SvgIcon-based, so they keep scaling off the explicit
|
||||
// width/height attributes below; the extra fontSize style is harmless for them.
|
||||
const propsToRender = {
|
||||
height: size,
|
||||
width: size,
|
||||
color,
|
||||
fill: color && 'currentColor',
|
||||
style: { ...style, color, fontSize: sizeValue },
|
||||
...restOfProps,
|
||||
};
|
||||
// Lucide renders a stroked (never filled) <svg> sized off real width/height attributes, so
|
||||
// `size` alone is authoritative — the old fontSize/1em dance MUI's SvgIcon needed is gone.
|
||||
// `flexShrink: 0` is the house default because an icon squashed by a flex sibling is the one
|
||||
// layout bug this component was silently reintroducing on every narrow row; a caller can still
|
||||
// override it through `style`.
|
||||
const styleToRender: CSSProperties = { flexShrink: 0, ...style, ...(color ? { color } : null) };
|
||||
|
||||
// Directional icons (back/chevron_start, …) are authored for LTR and mirror
|
||||
// under RTL via the single CSS rule in tokens.css keyed on this attribute —
|
||||
// under RTL via the single CSS rule in globals.css keyed on this attribute —
|
||||
// registering a name in DIRECTIONAL_ICONS (config.ts) is the only step a
|
||||
// later phase needs; never hand-roll a flip at the call site.
|
||||
const directionalProps = DIRECTIONAL_ICONS.has(iconName) ? { 'data-icon-directional': true } : undefined;
|
||||
|
||||
return <ComponentToRender data-icon={iconName} {...directionalProps} {...propsToRender} />;
|
||||
return (
|
||||
<ComponentToRender
|
||||
data-icon={iconName}
|
||||
{...directionalProps}
|
||||
size={size}
|
||||
color={color}
|
||||
strokeWidth={strokeWidth}
|
||||
style={styleToRender}
|
||||
{...restOfProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppIcon;
|
||||
|
||||
@@ -1,107 +1,123 @@
|
||||
// SVG assets — the Balinyaar brand mark
|
||||
import LogoMark from './icons/LogoMark';
|
||||
// MUI Icons — normalized to ONE visual family (Rounded): warmer, softer strokes
|
||||
// than the filled/outlined mix this registry used to carry, fitting the
|
||||
// clinical-but-human tone. Adding an icon = import here + one lowercase key
|
||||
// below; never import a raw MUI icon anywhere else in the app.
|
||||
import DefaultIcon from '@mui/icons-material/MoreHorizRounded';
|
||||
import CloseIcon from '@mui/icons-material/CloseRounded';
|
||||
import MenuIcon from '@mui/icons-material/MenuRounded';
|
||||
import SearchIcon from '@mui/icons-material/SearchRounded';
|
||||
import InfoIcon from '@mui/icons-material/InfoRounded';
|
||||
import HomeIcon from '@mui/icons-material/HomeRounded';
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircleRounded';
|
||||
import ExitToAppIcon from '@mui/icons-material/ExitToAppRounded';
|
||||
import NotificationsIcon from '@mui/icons-material/NotificationsRounded';
|
||||
import DangerousIcon from '@mui/icons-material/DangerousRounded';
|
||||
import EventNoteIcon from '@mui/icons-material/EventNoteRounded';
|
||||
import GroupsIcon from '@mui/icons-material/GroupsRounded';
|
||||
import PeopleAltIcon from '@mui/icons-material/PeopleAltRounded';
|
||||
import WalletIcon from '@mui/icons-material/AccountBalanceWalletRounded';
|
||||
import DashboardIcon from '@mui/icons-material/DashboardRounded';
|
||||
import VerifiedUserIcon from '@mui/icons-material/VerifiedUserRounded';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircleRounded';
|
||||
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmptyRounded';
|
||||
import CancelIcon from '@mui/icons-material/CancelRounded';
|
||||
import MedicalServicesIcon from '@mui/icons-material/MedicalServicesRounded';
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettingsRounded';
|
||||
import AddIcon from '@mui/icons-material/AddRounded';
|
||||
import EditIcon from '@mui/icons-material/EditRounded';
|
||||
import ArchiveIcon from '@mui/icons-material/Inventory2Rounded';
|
||||
import BankIcon from '@mui/icons-material/AccountBalanceRounded';
|
||||
import CameraIcon from '@mui/icons-material/PhotoCameraRounded';
|
||||
import WarningIcon from '@mui/icons-material/WarningAmberRounded';
|
||||
import LocationIcon from '@mui/icons-material/LocationOnRounded';
|
||||
import DeleteIcon from '@mui/icons-material/DeleteRounded';
|
||||
import CoverageIcon from '@mui/icons-material/MapRounded';
|
||||
// Catalog — nurse services surface + the customer Home service-category grid (f4/b5)
|
||||
import ServicesIcon from '@mui/icons-material/LocalOfferRounded';
|
||||
import CategoryIcon from '@mui/icons-material/CategoryRounded';
|
||||
import ElderlyIcon from '@mui/icons-material/ElderlyRounded';
|
||||
import PostSurgeryIcon from '@mui/icons-material/HealingRounded';
|
||||
import InfantIcon from '@mui/icons-material/ChildCareRounded';
|
||||
import ChronicIcon from '@mui/icons-material/MonitorHeartRounded';
|
||||
import CompanionshipIcon from '@mui/icons-material/VolunteerActivismRounded';
|
||||
// Verification — nurse trust flow (f5/b6): document upload, credential + identity, re-upload
|
||||
import UploadIcon from '@mui/icons-material/CloudUploadRounded';
|
||||
import DocumentIcon from '@mui/icons-material/InsertDriveFileRounded';
|
||||
import RefreshIcon from '@mui/icons-material/RefreshRounded';
|
||||
import IdentityIcon from '@mui/icons-material/BadgeRounded';
|
||||
import LicenseIcon from '@mui/icons-material/WorkspacePremiumRounded';
|
||||
import PublishIcon from '@mui/icons-material/RocketLaunchRounded';
|
||||
// Search & discovery — the customer nurse-finding flow (f6/b7): rating star, filter controls
|
||||
import StarIcon from '@mui/icons-material/StarRounded';
|
||||
import StarHalfIcon from '@mui/icons-material/StarHalfRounded';
|
||||
import TuneIcon from '@mui/icons-material/TuneRounded';
|
||||
import SortIcon from '@mui/icons-material/SortRounded';
|
||||
// Booking requests — the pre-payment intent flow (f7/b8): nurse inbox + the pay-&-continue handoff
|
||||
import RequestsIcon from '@mui/icons-material/AssignmentRounded';
|
||||
import PaymentIcon from '@mui/icons-material/CreditCardRounded';
|
||||
// Bookings, sessions & EVV — the post-payment engagement (f8/b9): check-in/out, GPS, care instructions
|
||||
import CheckInIcon from '@mui/icons-material/LoginRounded';
|
||||
import CheckOutIcon from '@mui/icons-material/LogoutRounded';
|
||||
import GpsIcon from '@mui/icons-material/MyLocationRounded';
|
||||
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
|
||||
import ClinicalIcon from '@mui/icons-material/HealthAndSafetyRounded';
|
||||
import MedicationIcon from '@mui/icons-material/MedicationRounded';
|
||||
import EmergencyIcon from '@mui/icons-material/LocalPhoneRounded';
|
||||
import PhoneIcon from '@mui/icons-material/PhoneRounded';
|
||||
import LockIcon from '@mui/icons-material/LockRounded';
|
||||
import NavigateIcon from '@mui/icons-material/NavigationRounded';
|
||||
// BNPL — the installment-checkout surface (f11/b12): installment plans + repayment schedule
|
||||
import InstallmentsIcon from '@mui/icons-material/PaymentsRounded';
|
||||
// Payouts — the nurse earnings & payout-history surface (f12/b13)
|
||||
import EarningsIcon from '@mui/icons-material/PaidRounded';
|
||||
// Reviews & patient care records (f13/b14): visit-note authoring, record tabs, family-ownership banner
|
||||
import NotesIcon from '@mui/icons-material/NoteAltRounded';
|
||||
import RoutineIcon from '@mui/icons-material/EventRepeatRounded';
|
||||
import TasksIcon from '@mui/icons-material/ChecklistRounded';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryRounded';
|
||||
import FamilyIcon from '@mui/icons-material/FamilyRestroomRounded';
|
||||
// Messaging (tickets) & notifications (f14/b15): support inbox + the message-send action
|
||||
import SupportIcon from '@mui/icons-material/SupportAgentRounded';
|
||||
import SendIcon from '@mui/icons-material/SendRounded';
|
||||
// Admin backoffice & partner consoles (f15/b15): config, holidays, audit, alerts, moderation, partners, refunds
|
||||
import CalendarIcon from '@mui/icons-material/CalendarMonthRounded';
|
||||
import AuditIcon from '@mui/icons-material/FactCheckRounded';
|
||||
import AlertsIcon from '@mui/icons-material/NotificationImportantRounded';
|
||||
import ModerationIcon from '@mui/icons-material/GavelRounded';
|
||||
import PartnersIcon from '@mui/icons-material/ApartmentRounded';
|
||||
import RolesIcon from '@mui/icons-material/ManageAccountsRounded';
|
||||
import RefundsIcon from '@mui/icons-material/CurrencyExchangeRounded';
|
||||
import DownloadIcon from '@mui/icons-material/FileDownloadRounded';
|
||||
import ExpandIcon from '@mui/icons-material/ExpandMoreRounded';
|
||||
import ExternalIcon from '@mui/icons-material/OpenInNewRounded';
|
||||
import AssignIcon from '@mui/icons-material/AssignmentIndRounded';
|
||||
// Cross-cutting affordances: back/forward navigation, share/copy, attachments
|
||||
import BackIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import ForwardIcon from '@mui/icons-material/ArrowForwardRounded';
|
||||
import ShareIcon from '@mui/icons-material/ShareRounded';
|
||||
import CopyIcon from '@mui/icons-material/ContentCopyRounded';
|
||||
import AttachmentIcon from '@mui/icons-material/AttachFileRounded';
|
||||
import LanguageIcon from '@mui/icons-material/TranslateRounded';
|
||||
// Auth & first-run (ui-phase-3): the onboarding relation fork needs a distinct glyph per option
|
||||
import FavoriteIcon from '@mui/icons-material/FavoriteRounded';
|
||||
/*
|
||||
* MUI Icons are gone. The registry now maps every name onto **Lucide**
|
||||
* (`lucide-react`) — a single contemporary outline family drawn on a 24px grid
|
||||
* with round caps/joins, which reads far lighter than the old filled/Rounded
|
||||
* glyphs at the small sizes a phone-width app actually uses.
|
||||
*
|
||||
* Two rules keep the set coherent:
|
||||
* 1. ONE family. Never import an icon from anywhere but `lucide-react` here,
|
||||
* and never import a raw icon component anywhere else in the app.
|
||||
* 2. The mapping is SEMANTIC, not incidental — a name describes the domain
|
||||
* concept ("verification", "earnings", "coverage") and the glyph is chosen
|
||||
* to depict that concept, so a rename of the underlying glyph never leaks
|
||||
* into call sites. Related concepts deliberately share a visual root
|
||||
* (every trust/verification name is a shield, every money name is a coin or
|
||||
* card, every clinical name is a pulse/cross).
|
||||
*
|
||||
* Adding an icon = one import here + one lowercase key in ICONS below.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
Archive,
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
Award,
|
||||
Baby,
|
||||
Bandage,
|
||||
Bell,
|
||||
BellRing,
|
||||
Building2,
|
||||
Calendar,
|
||||
CalendarCheck,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleCheckBig,
|
||||
CircleHelp,
|
||||
CircleUserRound,
|
||||
CircleX,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
CloudUpload,
|
||||
Coins,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Download,
|
||||
Ellipsis,
|
||||
ExternalLink,
|
||||
FileSearch,
|
||||
FileText,
|
||||
Gavel,
|
||||
HandHeart,
|
||||
Headset,
|
||||
Heart,
|
||||
HeartHandshake,
|
||||
HeartPulse,
|
||||
History,
|
||||
Hourglass,
|
||||
House,
|
||||
IdCard,
|
||||
Info,
|
||||
Landmark,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
ListChecks,
|
||||
LocateFixed,
|
||||
Lock,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Map,
|
||||
MapPin,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
Moon,
|
||||
Navigation,
|
||||
NotebookPen,
|
||||
OctagonAlert,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
PersonStanding,
|
||||
Phone,
|
||||
PhoneCall,
|
||||
Pill,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Rocket,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
Settings2,
|
||||
Share2,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
ShieldPlus,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
StarHalf,
|
||||
Stethoscope,
|
||||
Sun,
|
||||
SunMoon,
|
||||
Tag,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
TriangleAlert,
|
||||
Undo2,
|
||||
UserCog,
|
||||
UserPlus,
|
||||
Users,
|
||||
UsersRound,
|
||||
Wallet,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
@@ -110,108 +126,156 @@ export type IconName = keyof typeof ICONS;
|
||||
|
||||
/**
|
||||
* How to use:
|
||||
* 1. Import all required React, MUI or other SVG icons into this file.
|
||||
* 2. Add icons with "unique lowercase names" into ICONS object. Lowercase is a must!
|
||||
* 3. Use icons everywhere in the App by their names in <Icon icon="xxx" /> component
|
||||
* 1. Import the Lucide icon into this file.
|
||||
* 2. Add it with a "unique lowercase name" to the ICONS object. Lowercase is a must!
|
||||
* 3. Use it everywhere in the app by name: <AppIcon icon="xxx" />
|
||||
* Important: properties of ICONS object MUST be lowercase!
|
||||
* Note: You can use camelCase or UPPERCASE in the <Icon icon="someIconByName" /> component
|
||||
* Note: You can use camelCase or UPPERCASE in the <AppIcon icon="someIconByName" /> component
|
||||
*/
|
||||
export const ICONS /* Note: Setting type disables property autocomplete :( was - : Record<string, ComponentType> */ = {
|
||||
default: DefaultIcon,
|
||||
export const ICONS = {
|
||||
// ── Chrome & navigation ───────────────────────────────────────────────
|
||||
default: Ellipsis,
|
||||
logo: LogoMark,
|
||||
close: CloseIcon,
|
||||
menu: MenuIcon,
|
||||
search: SearchIcon,
|
||||
info: InfoIcon,
|
||||
home: HomeIcon,
|
||||
account: AccountCircleIcon,
|
||||
logout: ExitToAppIcon,
|
||||
notifications: NotificationsIcon,
|
||||
error: DangerousIcon,
|
||||
bookings: EventNoteIcon,
|
||||
patients: GroupsIcon,
|
||||
users: PeopleAltIcon,
|
||||
wallet: WalletIcon,
|
||||
profile: AccountCircleIcon,
|
||||
dashboard: DashboardIcon,
|
||||
verification: VerifiedUserIcon,
|
||||
verified: CheckCircleIcon,
|
||||
pending: HourglassEmptyIcon,
|
||||
rejected: CancelIcon,
|
||||
visits: MedicalServicesIcon,
|
||||
admin: AdminPanelSettingsIcon,
|
||||
add: AddIcon,
|
||||
edit: EditIcon,
|
||||
archive: ArchiveIcon,
|
||||
bank: BankIcon,
|
||||
camera: CameraIcon,
|
||||
warning: WarningIcon,
|
||||
location: LocationIcon,
|
||||
delete: DeleteIcon,
|
||||
coverage: CoverageIcon,
|
||||
services: ServicesIcon,
|
||||
category: CategoryIcon,
|
||||
elderly: ElderlyIcon,
|
||||
post_surgery: PostSurgeryIcon,
|
||||
infant: InfantIcon,
|
||||
chronic: ChronicIcon,
|
||||
companionship: CompanionshipIcon,
|
||||
upload: UploadIcon,
|
||||
document: DocumentIcon,
|
||||
refresh: RefreshIcon,
|
||||
identity: IdentityIcon,
|
||||
license: LicenseIcon,
|
||||
publish: PublishIcon,
|
||||
star: StarIcon,
|
||||
star_half: StarHalfIcon,
|
||||
tune: TuneIcon,
|
||||
sort: SortIcon,
|
||||
requests: RequestsIcon,
|
||||
payment: PaymentIcon,
|
||||
check_in: CheckInIcon,
|
||||
check_out: CheckOutIcon,
|
||||
gps: GpsIcon,
|
||||
schedule: ScheduleIcon,
|
||||
clinical: ClinicalIcon,
|
||||
medication: MedicationIcon,
|
||||
emergency: EmergencyIcon,
|
||||
phone: PhoneIcon,
|
||||
lock: LockIcon,
|
||||
navigate: NavigateIcon,
|
||||
installments: InstallmentsIcon,
|
||||
earnings: EarningsIcon,
|
||||
notes: NotesIcon,
|
||||
routine: RoutineIcon,
|
||||
tasks: TasksIcon,
|
||||
history: HistoryIcon,
|
||||
family: FamilyIcon,
|
||||
support: SupportIcon,
|
||||
send: SendIcon,
|
||||
config: TuneIcon,
|
||||
calendar: CalendarIcon,
|
||||
audit: AuditIcon,
|
||||
alerts: AlertsIcon,
|
||||
moderation: ModerationIcon,
|
||||
partners: PartnersIcon,
|
||||
roles: RolesIcon,
|
||||
refunds: RefundsIcon,
|
||||
download: DownloadIcon,
|
||||
expand: ExpandIcon,
|
||||
external: ExternalIcon,
|
||||
assign: AssignIcon,
|
||||
back: BackIcon,
|
||||
chevron_start: BackIcon,
|
||||
forward: ForwardIcon,
|
||||
share: ShareIcon,
|
||||
copy: CopyIcon,
|
||||
attachment: AttachmentIcon,
|
||||
language: LanguageIcon,
|
||||
favorite: FavoriteIcon,
|
||||
close: X,
|
||||
menu: Menu,
|
||||
more: Ellipsis,
|
||||
search: Search,
|
||||
info: Info,
|
||||
help: CircleHelp,
|
||||
home: House,
|
||||
account: CircleUserRound,
|
||||
logout: LogOut,
|
||||
notifications: Bell,
|
||||
error: OctagonAlert,
|
||||
settings: Settings,
|
||||
|
||||
// ── Actor destinations ────────────────────────────────────────────────
|
||||
bookings: CalendarDays,
|
||||
patients: HeartHandshake, // «حلقهٔ مراقبت» — a care circle, not a patient list
|
||||
users: UsersRound,
|
||||
wallet: Wallet,
|
||||
profile: CircleUserRound,
|
||||
dashboard: LayoutDashboard,
|
||||
today: CalendarCheck,
|
||||
practice: Stethoscope, // the nurse's «حرفهٔ من» group root
|
||||
visits: Stethoscope,
|
||||
admin: Shield,
|
||||
|
||||
// ── Trust & verification (all shields — one visual root) ──────────────
|
||||
verification: ShieldCheck,
|
||||
verified: CircleCheckBig,
|
||||
pending: Hourglass,
|
||||
rejected: CircleX,
|
||||
identity: IdCard,
|
||||
license: Award,
|
||||
|
||||
// ── Common actions ────────────────────────────────────────────────────
|
||||
add: Plus,
|
||||
edit: Pencil,
|
||||
archive: Archive,
|
||||
delete: Trash2,
|
||||
refresh: RefreshCw,
|
||||
upload: CloudUpload,
|
||||
download: Download,
|
||||
document: FileText,
|
||||
publish: Rocket,
|
||||
camera: Camera,
|
||||
warning: TriangleAlert,
|
||||
|
||||
// ── Geography & coverage ──────────────────────────────────────────────
|
||||
location: MapPin,
|
||||
coverage: Map,
|
||||
gps: LocateFixed,
|
||||
navigate: Navigation,
|
||||
|
||||
// ── Catalog — the customer Home category grid (f4/b5) ─────────────────
|
||||
services: Tag,
|
||||
category: LayoutGrid,
|
||||
elderly: PersonStanding,
|
||||
post_surgery: Bandage,
|
||||
infant: Baby,
|
||||
chronic: HeartPulse,
|
||||
companionship: HandHeart,
|
||||
|
||||
// ── Search & discovery ────────────────────────────────────────────────
|
||||
star: Star,
|
||||
star_half: StarHalf,
|
||||
tune: SlidersHorizontal,
|
||||
sort: ArrowUpDown,
|
||||
|
||||
// ── Booking requests, sessions & EVV ──────────────────────────────────
|
||||
requests: ClipboardList,
|
||||
check_in: LogIn,
|
||||
check_out: LogOut,
|
||||
schedule: Clock,
|
||||
clinical: ShieldPlus,
|
||||
medication: Pill,
|
||||
emergency: PhoneCall,
|
||||
phone: Phone,
|
||||
lock: Lock,
|
||||
|
||||
// ── Money (coins/cards — one visual root) ─────────────────────────────
|
||||
payment: CreditCard,
|
||||
installments: CalendarClock,
|
||||
earnings: Coins,
|
||||
bank: Landmark,
|
||||
refunds: Undo2,
|
||||
trending: TrendingUp,
|
||||
|
||||
// ── Reviews & patient care records ────────────────────────────────────
|
||||
notes: NotebookPen,
|
||||
routine: Repeat,
|
||||
tasks: ListChecks,
|
||||
history: History,
|
||||
family: Users,
|
||||
activity: Activity,
|
||||
|
||||
// ── Messaging & support ───────────────────────────────────────────────
|
||||
support: Headset,
|
||||
send: Send,
|
||||
|
||||
// ── Admin backoffice & partner consoles ───────────────────────────────
|
||||
config: Settings2,
|
||||
calendar: Calendar,
|
||||
audit: FileSearch,
|
||||
alerts: BellRing,
|
||||
moderation: Gavel,
|
||||
partners: Building2,
|
||||
roles: UserCog,
|
||||
assign: UserPlus,
|
||||
|
||||
// ── Cross-cutting affordances ─────────────────────────────────────────
|
||||
back: ArrowLeft,
|
||||
forward: ArrowRight,
|
||||
chevron_start: ChevronLeft,
|
||||
chevron_end: ChevronRight,
|
||||
expand: ChevronDown,
|
||||
external: ExternalLink,
|
||||
share: Share2,
|
||||
copy: Copy,
|
||||
attachment: Paperclip,
|
||||
switch: ArrowLeftRight,
|
||||
|
||||
// ── Settings surface ──────────────────────────────────────────────────
|
||||
language: Languages,
|
||||
appearance: SunMoon,
|
||||
light_mode: Sun,
|
||||
dark_mode: Moon,
|
||||
/** "Follow the operating system" — the third theme choice, not a device list. */
|
||||
system_mode: MonitorSmartphone,
|
||||
favorite: Heart,
|
||||
};
|
||||
|
||||
/**
|
||||
* Icons authored for LTR that must mirror horizontally under RTL (back arrows,
|
||||
* chevrons pointing "start"). AppIcon applies the flip via a `data-icon-directional`
|
||||
* attribute + the single CSS rule in globals.css — add a name here, nothing else.
|
||||
* chevrons pointing "start"/"end", the send paper-plane). AppIcon applies the flip via a
|
||||
* `data-icon-directional` attribute + the single CSS rule in globals.css — add a
|
||||
* name here, nothing else.
|
||||
*/
|
||||
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward', 'send']);
|
||||
export const DIRECTIONAL_ICONS = new Set<IconName>([
|
||||
'back',
|
||||
'chevron_start',
|
||||
'chevron_end',
|
||||
'forward',
|
||||
'send',
|
||||
]);
|
||||
|
||||
@@ -11,8 +11,15 @@ import { IconProps } from '../utils';
|
||||
* LogoLockup.tsx instead.
|
||||
* @component LogoMark
|
||||
*/
|
||||
const LogoMark: FunctionComponent<IconProps> = ({ color = 'currentColor', ...props }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" {...props}>
|
||||
const LogoMark: FunctionComponent<IconProps> = ({
|
||||
color = 'currentColor',
|
||||
size = 24,
|
||||
// Swallowed on purpose: the registry hands every icon Lucide's stroke weight, and this mark is
|
||||
// solid-fill — letting it through would emit a meaningless stroke-width attribute.
|
||||
strokeWidth: _strokeWidth,
|
||||
...props
|
||||
}) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width={size} height={size} {...props}>
|
||||
<path
|
||||
fill={color}
|
||||
fillRule="evenodd"
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { SVGAttributes } from 'react';
|
||||
|
||||
/**
|
||||
* Props to use with custom SVG icons, similar to AppIcon's Props
|
||||
* Props a custom SVG icon must accept to sit in the same registry as the Lucide set.
|
||||
* Mirrors Lucide's own `LucideProps` shape (`size` drives width/height, `color` paints,
|
||||
* `strokeWidth` is accepted and may be ignored by a solid-fill mark) so `AppIcon` can
|
||||
* render every registry entry through one uniform call.
|
||||
*/
|
||||
export interface IconProps extends SVGAttributes<SVGElement> {
|
||||
export interface IconProps extends Omit<SVGAttributes<SVGElement>, 'color'> {
|
||||
color?: string;
|
||||
icon?: string;
|
||||
size?: string | number;
|
||||
/** Accepted for contract parity with Lucide; a solid-fill brand mark ignores it. */
|
||||
strokeWidth?: string | number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@@ -38,8 +38,7 @@ describe('<AppIconButton/> component', () => {
|
||||
expect(svg).toHaveAttribute('data-icon', 'default'); // default icon
|
||||
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
|
||||
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
|
||||
// Size is actually driven by fontSize, not a `size` DOM attribute (invalid on <svg>).
|
||||
expect(svg).toHaveStyle(`font-size: ${APP_ICON_SIZE}px`);
|
||||
// Lucide sizes off the width/height attributes above — no fontSize/1em indirection left to assert.
|
||||
});
|
||||
|
||||
it('supports .color property', () => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import NavHubList from './NavHubList';
|
||||
|
||||
const ITEMS = [
|
||||
{ title: 'Earnings', subtitle: 'What you are owed', icon: 'earnings', path: '/nurse/earnings' },
|
||||
{ title: 'Bank', icon: 'bank', path: '/nurse/bank', badgeCount: 3 },
|
||||
];
|
||||
|
||||
function renderList() {
|
||||
return render(
|
||||
<NextIntlClientProvider locale="en" messages={{}}>
|
||||
<NavHubList items={ITEMS} title="Money" />
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('<NavHubList/> component', () => {
|
||||
it('renders one link per item, locale-prefixed', () => {
|
||||
renderList();
|
||||
expect(screen.getByRole('link', { name: /Earnings/ })).toHaveAttribute('href', '/en/nurse/earnings');
|
||||
expect(screen.getByRole('link', { name: /Bank/ })).toHaveAttribute('href', '/en/nurse/bank');
|
||||
});
|
||||
|
||||
it('renders the section title and item subtitles', () => {
|
||||
renderList();
|
||||
expect(screen.getByText('Money')).toBeInTheDocument();
|
||||
expect(screen.getByText('What you are owed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a badge only for an item that has a count', () => {
|
||||
renderList();
|
||||
// A zero/absent count must not render a stray dot next to the glyph.
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { Badge, Box, Divider, Link as MuiLink, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
|
||||
export interface NavHubItem {
|
||||
title: string;
|
||||
/** One line of orientation — what the destination is for, not a restatement of the title. */
|
||||
subtitle?: string;
|
||||
icon?: string;
|
||||
/** Locale-less `ROUTES.*` path; the locale-aware `Link` adds the prefix. */
|
||||
path: string;
|
||||
/** Unread/pending count rendered on the glyph. */
|
||||
badgeCount?: number;
|
||||
/** A short trailing value (a status chip, a count) shown before the chevron. */
|
||||
meta?: ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: Array<NavHubItem>;
|
||||
/** Optional section label rendered above the card. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const GLYPH_BOX = 36;
|
||||
|
||||
/**
|
||||
* The grouped list of destinations a group-root ("hub") page is built from — the surface that
|
||||
* replaced the sidebar. A drawer showed every destination in the app at once behind a hamburger;
|
||||
* a hub shows one area's destinations in place, with room for the subtitle and status the drawer
|
||||
* never had.
|
||||
* @component NavHubList
|
||||
*/
|
||||
const NavHubList: FunctionComponent<Props> = ({ items, title }) => (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{title ? (
|
||||
<Typography variant="overline" sx={{ color: 'text.secondary', paddingInlineStart: 0.5 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<SurfaceCard sx={{ p: 0, overflow: 'hidden' }}>
|
||||
<Stack divider={<Divider />}>
|
||||
{items.map((item) => (
|
||||
<MuiLink
|
||||
key={item.path}
|
||||
component={Link}
|
||||
href={item.path}
|
||||
color="inherit"
|
||||
underline="none"
|
||||
sx={{ display: 'block', '&:hover': { bgcolor: 'var(--bal-primary-soft)' } }}
|
||||
>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5, px: 2, py: 1.5, minWidth: 0 }}>
|
||||
<Badge
|
||||
badgeContent={item.badgeCount ?? 0}
|
||||
max={99}
|
||||
invisible={!item.badgeCount}
|
||||
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: GLYPH_BOX,
|
||||
height: GLYPH_BOX,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: 'var(--bal-radius-sm)',
|
||||
bgcolor: 'var(--bal-primary-soft)',
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={item.icon} size={18} color="var(--bal-primary)" />
|
||||
</Box>
|
||||
</Badge>
|
||||
|
||||
<Stack sx={{ gap: 0.125, minWidth: 0, flexGrow: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{item.title}
|
||||
</Typography>
|
||||
{item.subtitle ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{item.subtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{item.meta}
|
||||
<AppIcon icon="chevron_end" size={18} color="var(--bal-text-secondary)" aria-hidden="true" />
|
||||
</Stack>
|
||||
</MuiLink>
|
||||
))}
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default NavHubList;
|
||||
@@ -0,0 +1,4 @@
|
||||
import NavHubList from './NavHubList';
|
||||
|
||||
export type { NavHubItem } from './NavHubList';
|
||||
export { NavHubList as default, NavHubList };
|
||||
@@ -12,10 +12,23 @@ import { usePathname } from '@/i18n/navigation';
|
||||
* `globals.css` handles that for every consumer, this one included.
|
||||
* @component RouteFadeIn
|
||||
*/
|
||||
const RouteFadeIn: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
interface RouteFadeInProps extends PropsWithChildren {
|
||||
/**
|
||||
* Makes the animated box a full-height flex column. The chrome-free shells (auth, onboarding)
|
||||
* vertically center their card against the frame, which a plain auto-height wrapper in between
|
||||
* would silently break.
|
||||
*/
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
const RouteFadeIn: FunctionComponent<RouteFadeInProps> = ({ children, fill }) => {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<Box key={pathname} data-bal-route-fade sx={{ minWidth: 0 }}>
|
||||
<Box
|
||||
key={pathname}
|
||||
data-bal-route-fade
|
||||
sx={{ minWidth: 0, ...(fill && { flexGrow: 1, display: 'flex', flexDirection: 'column' }) }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import Pager from './Pager';
|
||||
import InitialsAvatar from './InitialsAvatar';
|
||||
import FormDialogShell from './FormDialogShell';
|
||||
import RouteFadeIn from './RouteFadeIn';
|
||||
import NavHubList from './NavHubList';
|
||||
|
||||
export {
|
||||
ErrorBoundary,
|
||||
@@ -50,6 +51,7 @@ export {
|
||||
InitialsAvatar,
|
||||
FormDialogShell,
|
||||
RouteFadeIn,
|
||||
NavHubList,
|
||||
};
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
export type { ErrorStateProps } from './ErrorState';
|
||||
@@ -67,3 +69,4 @@ export type { StickyActionBarProps } from './StickyActionBar';
|
||||
export type { PagerProps } from './Pager';
|
||||
export type { InitialsAvatarProps } from './InitialsAvatar';
|
||||
export type { FormDialogShellProps } from './FormDialogShell';
|
||||
export type { NavHubItem } from './NavHubList';
|
||||
|
||||
Reference in New Issue
Block a user