Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into feature/exam-attempt-domain

This commit is contained in:
nati14575
2026-08-21 21:04:34 +03:00
13 changed files with 370 additions and 106 deletions

View File

@@ -15,7 +15,7 @@ import {
import { TimeInput } from '@mantine/dates';
import { useDisclosure } from '@mantine/hooks';
import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic';
import { DayPicker as GregorianDayPicker } from '@daypicker/react';
import { DayPicker as GregorianDayPicker, type Matcher } from '@daypicker/react';
import { IconCalendarEvent } from '@tabler/icons-react';
import '@daypicker/react/dist/style.css';
import './AmharicDatePicker.css';
@@ -131,6 +131,11 @@ export interface AmharicDatePickerProps {
/** Show a time-of-day field alongside the calendar. Off by default —
* most callers only need a calendar day. */
withTime?: boolean;
/** Earliest/latest selectable day. Accepts a Date or a value in the same
* wire format as `value`. Days outside the range are disabled in both
* calendars. */
minDate?: Date | string;
maxDate?: Date | string;
/** Wire format for `value`/`onChange`: a full ISO-8601 instant (default,
* what most backend date fields expect) or a bare `yyyy-mm-dd` calendar
* date (what filter query params and plain `date: string` DTO fields
@@ -151,6 +156,8 @@ export function AmharicDatePicker({
onBlur,
w,
withTime = false,
minDate,
maxDate,
dateFormat = 'iso',
}: AmharicDatePickerProps) {
const { t, i18n } = useTranslation();
@@ -161,6 +168,21 @@ export function AmharicDatePicker({
const selected = parseWireValue(value, dateFormat, withTime);
const asDate = (limit: Date | string | undefined) =>
limit instanceof Date ? limit : parseWireValue(limit, dateFormat, withTime);
const min = asDate(minDate);
const max = asDate(maxDate);
const outOfRange: Matcher[] = [
...(min ? [{ before: min }] : []),
...(max ? [{ after: max }] : []),
];
// Compared as calendar days — the limits carry a midnight time-of-day, so
// an instant comparison would call today "after" a max of today.
const todayKey = formatPlainDate(new Date());
const todayOutOfRange =
(!!min && todayKey < formatPlainDate(min)) ||
(!!max && todayKey > formatPlainDate(max));
const dateLabel = selected
? calendarType === 'EN'
? selected.toLocaleDateString('en-US', {
@@ -266,6 +288,7 @@ export function AmharicDatePicker({
endMonth={YEAR_DROPDOWN_END}
numerals="latn"
captionLayout="dropdown"
disabled={outOfRange}
formatters={ETH_FORMATTERS}
onSelect={(date: Date | undefined) => {
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
@@ -281,6 +304,7 @@ export function AmharicDatePicker({
startMonth={YEAR_DROPDOWN_START}
endMonth={YEAR_DROPDOWN_END}
captionLayout="dropdown"
disabled={outOfRange}
onSelect={(date: Date | undefined) => {
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
if (!withTime) close();
@@ -389,6 +413,7 @@ export function AmharicDatePicker({
<Button
variant="light"
size="xs"
disabled={todayOutOfRange}
onClick={() => {
onChange?.(formatWireValue(new Date(), dateFormat, withTime));
close();

View File

@@ -14,6 +14,7 @@ import {
IconLogout,
IconUserCircle,
} from '@tabler/icons-react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { LanguageSwitcher } from './LanguageSwitcher';
import { ColorSchemeToggle } from './ColorSchemeToggle';
@@ -36,6 +37,17 @@ interface AppHeaderProps {
supportedLanguages: readonly string[];
onNotificationsClick?: () => void;
notificationCount?: number;
/**
* Rendered at the far left. The sidebar layout carries the brand in the
* sidebar itself; the top-bar layout has no sidebar, so it passes the brand
* here rather than leaving the chrome unbranded.
*/
brand?: ReactNode;
/**
* Breakpoint from which the burger is hidden. The top-bar layout only needs
* it on small screens, where the drawer replaces the nav strip.
*/
burgerHiddenFrom?: string;
}
export function AppHeader({
@@ -50,17 +62,21 @@ export function AppHeader({
supportedLanguages,
onNotificationsClick,
notificationCount,
brand,
burgerHiddenFrom,
}: AppHeaderProps) {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return (
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
{brand}
{/* Hamburger — styled like user-management Top.tsx */}
{/* The Burger itself owns the click so the control is a real, keyboard
reachable <button>; the Box is chrome only. It previously wrapped a
no-op button, which no keyboard user could operate. */}
<Box
hiddenFrom={burgerHiddenFrom}
style={{
display: 'flex',
alignItems: 'center',

View File

@@ -1,4 +1,5 @@
import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core';
import { forwardRef } from 'react';
import { IconChevronDown } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { NavItem } from './AppSidebar';
@@ -23,7 +24,9 @@ interface AppTopNavProps {
* scrolling strip, so with twenty-odd of them most were off-screen and the
* grouping that the sidebar already had was thrown away. Here each section
* collapses to a single labelled dropdown, which fits and keeps the same
* information architecture as the sidebar.
* information architecture as the sidebar. Sections that still do not fit
* scroll horizontally rather than dropping off the edge — under ~1100px the
* last one or two were simply unreachable.
*/
export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) {
const { t } = useTranslation();
@@ -36,6 +39,7 @@ export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps)
wrap="nowrap"
role="navigation"
aria-label={t('nav.primary', 'Primary')}
style={{ flex: 1, minWidth: 0, overflowX: 'auto', scrollbarWidth: 'none' }}
>
{sections.map((section, index) => {
// An unlabelled leading block (Dashboard) is a plain link, not a menu.
@@ -148,40 +152,52 @@ interface TopNavButtonProps {
onClick?: () => void;
}
function TopNavButton({
label,
active,
badge,
soon,
withChevron,
onClick,
}: TopNavButtonProps) {
return (
<UnstyledButton
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
padding: `0 ${rem(14)}`,
height: '100%',
borderBottom: '2px solid',
borderBottomColor: active ? 'var(--mantine-color-blue-6)' : 'transparent',
color: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)',
fontWeight: active ? 600 : 500,
fontSize: rem(14),
whiteSpace: 'nowrap',
opacity: soon ? 0.55 : 1,
marginBottom: -1,
}}
>
<span>{label}</span>
{badge !== null && badge !== undefined && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badge}
</Badge>
)}
{withChevron && <IconChevronDown size={14} stroke={2} />}
</UnstyledButton>
);
}
/**
* `Menu.Target` positions its dropdown against the ref it passes to its child,
* so a plain function component here left every section menu anchored at the
* top-left of the viewport, covering the header instead of opening under the
* button that was clicked. The rest props carry Menu's own click and aria
* handling onto the real button.
*/
const TopNavButton = forwardRef<HTMLButtonElement, TopNavButtonProps>(
function TopNavButton(
{ label, active, badge, soon, withChevron, onClick, ...others },
ref,
) {
return (
<UnstyledButton
ref={ref}
onClick={onClick}
{...others}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
padding: `0 ${rem(14)}`,
height: '100%',
flexShrink: 0,
borderBottom: '2px solid',
borderBottomColor: active
? 'var(--mantine-color-blue-6)'
: 'transparent',
color: active
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-gray-6)',
fontWeight: active ? 600 : 500,
fontSize: rem(14),
whiteSpace: 'nowrap',
opacity: soon ? 0.55 : 1,
marginBottom: -1,
}}
>
<span>{label}</span>
{badge !== null && badge !== undefined && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badge}
</Badge>
)}
{withChevron && <IconChevronDown size={14} stroke={2} />}
</UnstyledButton>
);
},
);