ui phase 5

This commit is contained in:
hamid
2026-07-18 09:51:03 +03:30
parent 53b4e1b0a4
commit 4c70d8e424
42 changed files with 2834 additions and 548 deletions
@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useLocale: () => 'en',
}));
import JalaliDateIntentPicker from './JalaliDateIntentPicker';
import { todayIso } from '../JalaliDatePicker/calendarEngine';
const LABELS = { todayLabel: 'Today', tomorrowLabel: 'Tomorrow', pickOtherLabel: 'Pick another date' };
function wrap(ui: React.ReactNode) {
return render(<ThemeProvider>{ui}</ThemeProvider>);
}
describe('<JalaliDateIntentPicker/> component', () => {
it('renders the near-date chip strip and emits the ISO date on click', () => {
const onChange = jest.fn();
const { container } = wrap(<JalaliDateIntentPicker value="" onChange={onChange} min={todayIso()} {...LABELS} />);
const chips = container.querySelectorAll('[data-day]');
expect(chips.length).toBeGreaterThan(0);
fireEvent.click(chips[0]);
expect(onChange).toHaveBeenCalledWith(todayIso());
});
it('opens a full-grid popover from the calendar-icon button', () => {
wrap(<JalaliDateIntentPicker value="" onChange={jest.fn()} min={todayIso()} {...LABELS} />);
fireEvent.click(screen.getByLabelText('Pick another date'));
expect(screen.getByText(/\d{4}/)).toBeInTheDocument();
});
it('closes the popover and emits the picked date when a grid day is clicked', () => {
const onChange = jest.fn();
wrap(<JalaliDateIntentPicker value={todayIso()} onChange={onChange} {...LABELS} />);
fireEvent.click(screen.getByLabelText('Pick another date'));
const gridDay = screen.getAllByText('15')[0];
fireEvent.click(gridDay);
expect(onChange).toHaveBeenCalled();
});
});
@@ -0,0 +1,83 @@
'use client';
import { FunctionComponent, useState } from 'react';
import Box from '@mui/material/Box';
import Popover from '@mui/material/Popover';
import Stack from '@mui/material/Stack';
import AppIconButton from '../AppIconButton';
import JalaliDatePicker from '../JalaliDatePicker';
export interface JalaliDateIntentPickerProps {
/** The selected date as a wire ISO (Gregorian) `YYYY-MM-DD` string, or `''` for no selection. */
value: string;
/** Fired with the wire ISO (Gregorian) date of the day the user picked. */
onChange: (iso: string) => void;
/** Inclusive ISO lower bound (typically `todayIso()`). */
min?: string;
/** Number of day-chips in the near strip (default 7). */
chipDayCount?: number;
/** Already-translated relative labels for the first two chips when they land on today/tomorrow. */
todayLabel: string;
tomorrowLabel: string;
/** Already-translated accessible label for the "pick another date" calendar-icon entry. */
pickOtherLabel: string;
}
/**
* A horizontal near-date chip strip (`JalaliDatePicker` `chips` variant) plus a calendar-icon entry into
* the full Jalali grid (a `Popover`) for dates beyond the strip — the shape both C1 search (date-intent,
* never hard-filters) and C4 booking-request (a real required field) need. Presentational, caller-owned
* copy, per the `components/common` convention.
* @component JalaliDateIntentPicker
*/
const JalaliDateIntentPicker: FunctionComponent<JalaliDateIntentPickerProps> = ({
value,
onChange,
min,
chipDayCount = 7,
todayLabel,
tomorrowLabel,
pickOtherLabel,
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<JalaliDatePicker
variant="chips"
chipDayCount={chipDayCount}
value={value || null}
onChange={onChange}
min={min}
todayLabel={todayLabel}
tomorrowLabel={tomorrowLabel}
/>
</Box>
<AppIconButton
icon="calendar"
title={pickOtherLabel}
onClick={(event) => setAnchorEl(event.currentTarget)}
/>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Box sx={{ p: 2, width: 320 }}>
<JalaliDatePicker
value={value || null}
min={min}
onChange={(iso) => {
onChange(iso);
setAnchorEl(null);
}}
/>
</Box>
</Popover>
</Stack>
);
};
export default JalaliDateIntentPicker;
@@ -0,0 +1,4 @@
import JalaliDateIntentPicker from './JalaliDateIntentPicker';
export default JalaliDateIntentPicker;
export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker';
@@ -26,6 +26,10 @@ export interface JalaliDatePickerProps {
/** Already-translated accessible label for the previous/next-month buttons. */
prevMonthLabel?: string;
nextMonthLabel?: string;
/** `chips` variant only: overrides the weekday label of the first chip with "today"/"tomorrow" copy
* when that chip's date genuinely is today/tomorrow (never forced when a `min` pushes the strip later). */
todayLabel?: string;
tomorrowLabel?: string;
}
const GRID_COLUMNS = 7;
@@ -47,6 +51,8 @@ const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({
chipDayCount = 14,
prevMonthLabel,
nextMonthLabel,
todayLabel,
tomorrowLabel,
}) => {
const locale = useLocale();
const engine = useMemo(() => engineForLocale(locale), [locale]);
@@ -77,6 +83,9 @@ const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({
const weekday = new Intl.DateTimeFormat(localeTag(locale), { weekday: 'short' }).format(
new Date(`${dayIso}T00:00:00`),
);
const relativeLabel =
dayIso === todayIso() ? todayLabel : dayIso === addDaysIso(todayIso(), 1) ? tomorrowLabel : undefined;
const topLabel = relativeLabel ?? weekday;
return (
<Box
key={dayIso}
@@ -104,8 +113,8 @@ const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({
opacity: disabled ? 0.5 : 1,
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{weekday}
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: relativeLabel ? 700 : 400 }}>
{topLabel}
</Typography>
<Typography variant="body2" sx={{ fontWeight: selected ? 700 : 500 }}>
{formatNumber(cell.day, locale)}
@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import StickyActionBar from './StickyActionBar';
describe('<StickyActionBar/> component', () => {
it('renders its children inside a sticky-bottom container', () => {
const { container } = render(
<ThemeProvider>
<StickyActionBar>
<button type="button">مشاهده ۱۲ پرستار</button>
</StickyActionBar>
</ThemeProvider>,
);
expect(screen.getByText('مشاهده ۱۲ پرستار')).toBeInTheDocument();
const bar = container.querySelector('[data-sticky-action-bar]');
expect(bar).toBeInTheDocument();
expect(bar).toHaveStyle({ position: 'sticky', bottom: '0px' });
});
});
@@ -0,0 +1,35 @@
import { FunctionComponent, ReactNode } from 'react';
import Box from '@mui/material/Box';
export interface StickyActionBarProps {
children: ReactNode;
}
/**
* A bottom-pinned action bar for a scrolling screen — the C1 live-count CTA and the C3 booking CTA.
* Rendered as the last child of a page's scrolling content, `position: sticky` pins it to the bottom
* of the nearest scrolling ancestor (the shell's `main`), so on mobile it naturally sits directly above
* `BottomBar` (a separate flex sibling below `main`, which already owns the `env(safe-area-inset-bottom)`
* padding — this component does not re-implement it) and on desktop it sits at the viewport bottom.
* @component StickyActionBar
*/
const StickyActionBar: FunctionComponent<StickyActionBarProps> = ({ children }) => (
<Box
data-sticky-action-bar
sx={{
position: 'sticky',
bottom: 0,
zIndex: 1,
mt: 2,
pt: 1.5,
pb: 1.5,
bgcolor: 'background.default',
borderTop: '1px solid',
borderColor: 'divider',
}}
>
{children}
</Box>
);
export default StickyActionBar;
@@ -0,0 +1,4 @@
import StickyActionBar from './StickyActionBar';
export default StickyActionBar;
export type { StickyActionBarProps } from './StickyActionBar';
+6
View File
@@ -16,7 +16,9 @@ import Money from './Money';
import StatusTimeline from './StatusTimeline';
import JalaliDatePicker from './JalaliDatePicker';
import JalaliDateField from './JalaliDateField';
import JalaliDateIntentPicker from './JalaliDateIntentPicker';
import LocaleSwitcher from './LocaleSwitcher';
import StickyActionBar from './StickyActionBar';
export {
ErrorBoundary,
@@ -37,7 +39,9 @@ export {
StatusTimeline,
JalaliDatePicker,
JalaliDateField,
JalaliDateIntentPicker,
LocaleSwitcher,
StickyActionBar,
};
export type { EmptyStateProps } from './EmptyState';
export type { ErrorStateProps } from './ErrorState';
@@ -50,3 +54,5 @@ export type { MoneyProps } from './Money';
export type { StatusTimelineProps, TimelineNode, TimelineNodeState } from './StatusTimeline';
export type { JalaliDatePickerProps } from './JalaliDatePicker';
export type { JalaliDateFieldProps } from './JalaliDateField';
export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker';
export type { StickyActionBarProps } from './StickyActionBar';