redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
interface AccessibleIconProps {
|
||||
icon: ReactElement;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function AccessibleIcon({ icon, label }: AccessibleIconProps) {
|
||||
if (label) {
|
||||
return (
|
||||
<span role="img" aria-label={label}>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span aria-hidden="true">{icon}</span>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.noWrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { classNames } from '@/lib/components/classNames';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import styles from './BidiText.module.css';
|
||||
|
||||
interface BidiTextProps extends Omit<HTMLAttributes<HTMLElement>, 'dir'> {
|
||||
children: ReactNode;
|
||||
direction?: 'ltr' | 'rtl' | 'auto';
|
||||
noWrap?: boolean;
|
||||
}
|
||||
|
||||
export function BidiText({
|
||||
children,
|
||||
direction = 'ltr',
|
||||
noWrap = true,
|
||||
className,
|
||||
...props
|
||||
}: BidiTextProps) {
|
||||
return (
|
||||
<bdi dir={direction} className={classNames(noWrap && styles.noWrap, className)} {...props}>
|
||||
{children}
|
||||
</bdi>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
.icon {
|
||||
flex: none;
|
||||
inline-size: 1.25rem;
|
||||
block-size: 1.25rem;
|
||||
}
|
||||
.sm {
|
||||
inline-size: 1rem;
|
||||
block-size: 1rem;
|
||||
}
|
||||
.md {
|
||||
inline-size: 1.25rem;
|
||||
block-size: 1.25rem;
|
||||
}
|
||||
.lg {
|
||||
inline-size: 1.5rem;
|
||||
block-size: 1.5rem;
|
||||
}
|
||||
html[dir='rtl'] .mirror {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.icon {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { classNames } from '@/lib/components/classNames';
|
||||
import type { ReactNode, SVGProps } from 'react';
|
||||
import styles from './Icon.module.css';
|
||||
|
||||
export const approvedIconNames = [
|
||||
'arrow-back',
|
||||
'arrow-forward',
|
||||
'calendar',
|
||||
'car',
|
||||
'check',
|
||||
'chevron-down',
|
||||
'chevron-forward',
|
||||
'close',
|
||||
'error',
|
||||
'external',
|
||||
'globe',
|
||||
'info',
|
||||
'menu',
|
||||
'minus',
|
||||
'plus',
|
||||
'search',
|
||||
'settings',
|
||||
'spinner',
|
||||
'warning',
|
||||
] as const;
|
||||
|
||||
export type ApprovedIconName = (typeof approvedIconNames)[number];
|
||||
export type IconSize = 'sm' | 'md' | 'lg';
|
||||
export type IconDirectionBehavior = 'fixed' | 'mirror';
|
||||
|
||||
interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'name'> {
|
||||
name: ApprovedIconName;
|
||||
size?: IconSize;
|
||||
decorative?: boolean;
|
||||
label?: string;
|
||||
directionBehavior?: IconDirectionBehavior;
|
||||
}
|
||||
|
||||
const paths: Record<ApprovedIconName, ReactNode> = {
|
||||
'arrow-back': <path d="m15 18-6-6 6-6M9 12h10" />,
|
||||
'arrow-forward': <path d="m9 18 6-6-6-6m6 6H5" />,
|
||||
calendar: (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M16 3v4M8 3v4M3 10h18" />
|
||||
</>
|
||||
),
|
||||
car: (
|
||||
<>
|
||||
<path d="m5 11 2-5h10l2 5" />
|
||||
<path d="M3 11h18v7H3zM6 18v2m12-2v2M7 14h.01M17 14h.01" />
|
||||
</>
|
||||
),
|
||||
check: <path d="m5 12 4 4L19 6" />,
|
||||
'chevron-down': <path d="m6 9 6 6 6-6" />,
|
||||
'chevron-forward': <path d="m9 18 6-6-6-6" />,
|
||||
close: <path d="M6 6l12 12M18 6 6 18" />,
|
||||
error: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v6m0 4h.01" />
|
||||
</>
|
||||
),
|
||||
external: (
|
||||
<>
|
||||
<path d="M14 4h6v6M20 4l-9 9" />
|
||||
<path d="M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6" />
|
||||
</>
|
||||
),
|
||||
globe: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" />
|
||||
</>
|
||||
),
|
||||
info: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v6m0-10h.01" />
|
||||
</>
|
||||
),
|
||||
menu: <path d="M4 7h16M4 12h16M4 17h16" />,
|
||||
minus: <path d="M5 12h14" />,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m16 16 5 5" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
spinner: <path d="M21 12a9 9 0 1 1-9-9" />,
|
||||
warning: (
|
||||
<>
|
||||
<path d="M12 3 2.5 20h19L12 3Z" />
|
||||
<path d="M12 9v5m0 3h.01" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size = 'md',
|
||||
decorative = true,
|
||||
label,
|
||||
directionBehavior = 'fixed',
|
||||
className,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const accessibleProps = decorative
|
||||
? ({ 'aria-hidden': true } as const)
|
||||
: ({ role: 'img', 'aria-label': label ?? name } as const);
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
focusable="false"
|
||||
className={classNames(
|
||||
styles.icon,
|
||||
styles[size],
|
||||
directionBehavior === 'mirror' && styles.mirror,
|
||||
className,
|
||||
)}
|
||||
{...accessibleProps}
|
||||
{...props}
|
||||
>
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.region {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import styles from './LiveRegion.module.css';
|
||||
|
||||
interface LiveRegionProps {
|
||||
children: ReactNode;
|
||||
politeness?: 'polite' | 'assertive';
|
||||
}
|
||||
|
||||
export function LiveRegion({ children, politeness = 'polite' }: LiveRegionProps) {
|
||||
return (
|
||||
<span className={styles.region} role="status" aria-live={politeness} aria-atomic="true">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
min-block-size: 1.75rem;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
inline-size: fit-content;
|
||||
padding-block: var(--space-1);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--type-caption-size);
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.neutral {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.info {
|
||||
background: var(--status-info-surface);
|
||||
color: var(--status-info-text);
|
||||
}
|
||||
.success {
|
||||
background: var(--status-success-surface);
|
||||
color: var(--status-success-text);
|
||||
}
|
||||
.warning {
|
||||
background: var(--status-warning-surface);
|
||||
color: var(--status-warning-text);
|
||||
}
|
||||
.error {
|
||||
background: var(--status-error-surface);
|
||||
color: var(--status-error-text);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Icon, type ApprovedIconName } from './Icon';
|
||||
import styles from './StatusBadge.module.css';
|
||||
|
||||
export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
const icons: Partial<Record<StatusTone, ApprovedIconName>> = {
|
||||
info: 'info',
|
||||
success: 'check',
|
||||
warning: 'warning',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
children,
|
||||
tone = 'neutral',
|
||||
showIcon = false,
|
||||
}: {
|
||||
children: string;
|
||||
tone?: StatusTone;
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
const icon = icons[tone];
|
||||
return (
|
||||
<span className={`${styles.badge} ${styles[tone]}`}>
|
||||
{showIcon && icon ? <Icon name={icon} size="sm" /> : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface VisuallyHiddenProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function VisuallyHidden({ children, className, ...props }: VisuallyHiddenProps) {
|
||||
const classes = ['visually-hidden', className].filter(Boolean).join(' ');
|
||||
return (
|
||||
<span className={classes} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useState, useSyncExternalStore, type RefObject } from 'react';
|
||||
|
||||
export function useAccessibleId(prefix = 'rdg'): string {
|
||||
return `${prefix}-${useId().replaceAll(':', '')}`;
|
||||
}
|
||||
|
||||
const subscribeHydration = () => () => undefined;
|
||||
|
||||
export function useHydrated(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeHydration,
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
const update = () => setMatches(media.matches);
|
||||
update();
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function useReducedMotion(): boolean {
|
||||
return useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||
}
|
||||
|
||||
export function useEscapeKey(onEscape: () => void, enabled = true): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onEscape();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [enabled, onEscape]);
|
||||
}
|
||||
|
||||
export function useOutsideClick<T extends HTMLElement>(
|
||||
ref: RefObject<T | null>,
|
||||
onOutside: () => void,
|
||||
enabled = true,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) onOutside();
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, [enabled, onOutside, ref]);
|
||||
}
|
||||
|
||||
export function useScrollLock(locked: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!locked) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const previousPadding = document.body.style.paddingInlineEnd;
|
||||
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (scrollbar > 0) document.body.style.paddingInlineEnd = `${scrollbar}px`;
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.body.style.paddingInlineEnd = previousPadding;
|
||||
};
|
||||
}, [locked]);
|
||||
}
|
||||
Reference in New Issue
Block a user