Initial commit
This commit is contained in:
100
src/components/ds/ActivityCard.jsx
Normal file
100
src/components/ds/ActivityCard.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar } from '@/components/ds/Avatar';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { StatusBadge } from '@/components/ds/StatusBadge';
|
||||
|
||||
/**
|
||||
* ActivityCard — one event in a feed: who did what, when.
|
||||
*
|
||||
* Renders as a row by default (feeds are dense) and as a bordered card when
|
||||
* `variant="card"`. Either way the anatomy is fixed — actor, sentence, meta,
|
||||
* trailing slot — so a feed built from mixed event types stays aligned.
|
||||
*/
|
||||
export function ActivityCard({
|
||||
/** Person or system that acted. */
|
||||
actor,
|
||||
actorSrc,
|
||||
/** Icon shown instead of an avatar, for system events. */
|
||||
icon: Icon,
|
||||
iconTone = 'soft',
|
||||
/** What happened — "Screened Sofia Mendez". */
|
||||
title,
|
||||
/** Supporting detail. */
|
||||
description,
|
||||
timestamp,
|
||||
status,
|
||||
/** Right-aligned slot — a score, a button. */
|
||||
trailing,
|
||||
variant = 'row',
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
const iconTones = {
|
||||
soft: 'bg-krow-blue-tint text-krow-blue',
|
||||
brand: 'bg-krow-blue text-white',
|
||||
success: 'bg-success-muted text-success',
|
||||
warning: 'bg-warning-muted text-warning',
|
||||
destructive: 'bg-destructive-muted text-destructive',
|
||||
neutral: 'bg-surface-sunken text-ink-3',
|
||||
};
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{Icon ? (
|
||||
<span className={cn('grid place-items-center w-9 h-9 rounded-xl shrink-0', iconTones[iconTone])}>
|
||||
<Icon className="w-4 h-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<Avatar name={actor} src={actorSrc} size="default" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-body-sm text-ink-1">
|
||||
{actor && <span className="font-semibold">{actor}</span>}
|
||||
{actor && title && ' '}
|
||||
{title && <span className={actor ? 'text-ink-2' : 'font-medium'}>{title}</span>}
|
||||
</p>
|
||||
{description && <p className="text-caption text-ink-3 mt-0.5 truncate-2">{description}</p>}
|
||||
{timestamp && <p className="text-caption text-ink-4 mt-1">{timestamp}</p>}
|
||||
</div>
|
||||
|
||||
{(status || trailing) && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{status && <StatusBadge status={status} size="sm" />}
|
||||
{trailing}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<Surface
|
||||
as={onClick ? 'button' : 'div'}
|
||||
onClick={onClick}
|
||||
interactive={onClick ? true : undefined}
|
||||
variant="solid"
|
||||
radius="lg"
|
||||
padding="sm"
|
||||
className={cn('flex items-start gap-3 w-full text-left', className)}
|
||||
>
|
||||
{body}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
const Comp = onClick ? 'button' : 'div';
|
||||
return (
|
||||
<Comp
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-start gap-3 w-full text-left rounded-xl p-2.5 transition-colors duration-fast',
|
||||
onClick && 'hover:bg-white/60 cursor-pointer focus-ring',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
71
src/components/ds/Alert.jsx
Normal file
71
src/components/ds/Alert.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva('flex items-start gap-3 rounded-xl border p-4 animate-slide-down', {
|
||||
variants: {
|
||||
tone: {
|
||||
info: 'bg-info-muted border-krow-blue/20 text-ink-2',
|
||||
success: 'bg-success-muted border-success/20 text-ink-2',
|
||||
warning: 'bg-warning-muted border-warning/25 text-ink-2',
|
||||
destructive: 'bg-destructive-muted border-destructive/20 text-ink-2',
|
||||
neutral: 'bg-surface-subtle border-border text-ink-2',
|
||||
},
|
||||
},
|
||||
defaultVariants: { tone: 'info' },
|
||||
});
|
||||
|
||||
const ICONS = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: TriangleAlert,
|
||||
destructive: AlertCircle,
|
||||
neutral: Info,
|
||||
};
|
||||
|
||||
const ICON_COLORS = {
|
||||
info: 'text-krow-blue',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
neutral: 'text-ink-4',
|
||||
};
|
||||
|
||||
/**
|
||||
* Alert — an inline, persistent message about the state of the page.
|
||||
*
|
||||
* For transient confirmation of something the user just did, use `toast`
|
||||
* instead: an Alert that appears and disappears is easy to miss, and a toast
|
||||
* that persists is noise.
|
||||
*/
|
||||
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
|
||||
const Icon = icon || ICONS[tone];
|
||||
|
||||
return (
|
||||
<div role="alert" className={cn(alertVariants({ tone }), className)} {...props}>
|
||||
{Icon && <Icon className={cn('w-4.5 h-4.5 shrink-0 mt-0.5', ICON_COLORS[tone])} aria-hidden="true" />}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && <p className="text-body-sm font-semibold text-ink-1">{title}</p>}
|
||||
{children && (
|
||||
<div className={cn('text-body-sm text-ink-2 leading-relaxed', title && 'mt-0.5')}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{action && <div className="mt-3">{action}</div>}
|
||||
</div>
|
||||
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
className="grid place-items-center w-6 h-6 rounded-md text-ink-4 hover:text-ink-1 hover:bg-black/5 transition-colors shrink-0 focus-ring-inset"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/components/ds/Avatar.jsx
Normal file
119
src/components/ds/Avatar.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SIZES = {
|
||||
xs: 'w-6 h-6 text-[9px]',
|
||||
sm: 'w-7 h-7 text-[10px]',
|
||||
default: 'w-9 h-9 text-[12px]',
|
||||
lg: 'w-11 h-11 text-body-sm',
|
||||
xl: 'w-16 h-16 text-title',
|
||||
'2xl': 'w-20 h-20 text-title-lg',
|
||||
};
|
||||
|
||||
const avatarVariants = cva(
|
||||
'relative inline-grid place-items-center shrink-0 overflow-hidden font-semibold select-none',
|
||||
{
|
||||
variants: {
|
||||
shape: { circle: 'rounded-full', rounded: 'rounded-xl' },
|
||||
tone: {
|
||||
brand: 'bg-krow-blue text-white',
|
||||
navy: 'bg-krow-navy text-white',
|
||||
soft: 'bg-krow-blue-tint text-krow-blue',
|
||||
neutral: 'bg-surface-sunken text-ink-3',
|
||||
},
|
||||
},
|
||||
defaultVariants: { shape: 'circle', tone: 'brand' },
|
||||
}
|
||||
);
|
||||
|
||||
/** First letters of the first two words — "Chef Antoine Dubois" → "CA". */
|
||||
export function initialsFrom(name = '') {
|
||||
const words = String(name).trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return '?';
|
||||
return words.slice(0, 2).map((w) => w[0]).join('').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar — a photo when one exists, initials otherwise.
|
||||
*
|
||||
* The app shows people constantly and most have no photo, so initials are a
|
||||
* first-class state rather than a fallback afterthought. A failed image load
|
||||
* degrades to initials instead of a broken-image icon.
|
||||
*/
|
||||
export const Avatar = React.forwardRef(
|
||||
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
|
||||
const [failed, setFailed] = React.useState(false);
|
||||
const showImage = src && !failed;
|
||||
|
||||
// Reset when the subject changes, so a new src gets its own chance to load.
|
||||
React.useEffect(() => setFailed(false), [src]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className={cn('relative inline-block shrink-0', className)} {...props}>
|
||||
<span
|
||||
className={cn(avatarVariants({ shape, tone }), SIZES[size] || SIZES.default)}
|
||||
aria-hidden={showImage ? 'true' : undefined}
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={name ? `${name}` : ''}
|
||||
onError={() => setFailed(true)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span aria-label={name || undefined}>{initialsFrom(name)}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{status && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bottom-0 right-0 block rounded-full ring-2 ring-white',
|
||||
size === 'xs' || size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5',
|
||||
status === 'online' && 'bg-success',
|
||||
status === 'busy' && 'bg-warning',
|
||||
status === 'offline' && 'bg-ink-4'
|
||||
)}
|
||||
aria-label={status}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
Avatar.displayName = 'Avatar';
|
||||
|
||||
/**
|
||||
* AvatarGroup — overlapping avatars with a "+N" remainder, for showing several
|
||||
* people in a tight row (applicants on a position, endorsers on a skill).
|
||||
*/
|
||||
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
|
||||
const visible = people.slice(0, max);
|
||||
const overflow = people.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center', className)}>
|
||||
{visible.map((person, i) => (
|
||||
<Avatar
|
||||
key={person.id ?? person.name ?? i}
|
||||
name={person.name}
|
||||
src={person.src}
|
||||
size={size}
|
||||
className={cn('ring-2 ring-white', i > 0 && '-ml-2')}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center rounded-full bg-surface-sunken text-ink-3 font-semibold ring-2 ring-white -ml-2',
|
||||
SIZES[size] || SIZES.default
|
||||
)}
|
||||
>
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
src/components/ds/ChartContainer.jsx
Normal file
172
src/components/ds/ChartContainer.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import * as React from 'react';
|
||||
import { ResponsiveContainer } from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { SectionHeader } from '@/components/ds/SectionHeader';
|
||||
import { EmptyState } from '@/components/ds/EmptyState';
|
||||
import { SkeletonChart } from '@/components/ds/Skeleton';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The chart palette. Recharts needs literal colours, not Tailwind classes, so
|
||||
* the brand values are mirrored here — this array is the only place a chart
|
||||
* should get a colour from.
|
||||
*/
|
||||
export const CHART_COLORS = ['#0A39DF', '#F9E547', '#333F48', '#F8E08E', '#D1E0D7', '#082EB4'];
|
||||
|
||||
export const CHART_TONES = {
|
||||
brand: '#0A39DF',
|
||||
brandDark: '#082EB4',
|
||||
accent: '#F9E547',
|
||||
accentPale: '#F8E08E',
|
||||
navy: '#333F48',
|
||||
mint: '#D1E0D7',
|
||||
grid: 'hsl(150 8% 87%)',
|
||||
axis: 'hsl(220 9% 64%)',
|
||||
};
|
||||
|
||||
/** Shared axis props so every chart's ticks match. */
|
||||
export const AXIS_PROPS = {
|
||||
stroke: CHART_TONES.axis,
|
||||
tick: { fontSize: 11, fill: CHART_TONES.axis },
|
||||
tickLine: false,
|
||||
axisLine: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* A tooltip matching the design system's surfaces. Recharts' default is a plain
|
||||
* white box that ignores the token set.
|
||||
*/
|
||||
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface px-3 py-2 shadow-lg">
|
||||
{label != null && (
|
||||
<p className="text-caption font-semibold text-ink-1 mb-1">
|
||||
{labelFormatter ? labelFormatter(label) : label}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{payload.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-caption">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: entry.color || entry.fill }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-ink-3">{entry.name}</span>
|
||||
<span className="ml-auto font-semibold text-ink-1 tabular-nums">
|
||||
{valueFormatter ? valueFormatter(entry.value) : entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A legend that reads as part of the card rather than as chart furniture.
|
||||
* Items: `{ label, color, value? }`.
|
||||
*/
|
||||
export function ChartLegend({ items = [], className }) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-caption text-ink-3">{item.label}</span>
|
||||
{item.value != null && (
|
||||
<span className="text-caption font-semibold text-ink-1 tabular-nums">{item.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ChartContainer — the card a chart lives in.
|
||||
*
|
||||
* Owns the title row, the fixed plot height, the loading skeleton, the empty
|
||||
* state, and an optional headline figure. Charts inside it are pure Recharts
|
||||
* children, so swapping a bar chart for a line chart changes nothing else.
|
||||
*/
|
||||
export function ChartContainer({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
/** A big number in the top-right — an average, a total. */
|
||||
headline,
|
||||
headlineLabel,
|
||||
/** Right-aligned controls, e.g. a SegmentedToggle for the range. */
|
||||
actions,
|
||||
legend,
|
||||
height = 260,
|
||||
loading = false,
|
||||
isEmpty = false,
|
||||
emptyMessage = 'No data for this period',
|
||||
children,
|
||||
/** Skips ResponsiveContainer for children that are not Recharts charts. */
|
||||
raw = false,
|
||||
footer,
|
||||
className,
|
||||
}) {
|
||||
if (loading) return <SkeletonChart className={className} height={height} />;
|
||||
|
||||
return (
|
||||
<Surface radius="xl" padding="lg" className={cn('flex flex-col', className)}>
|
||||
{(title || headline || actions) && (
|
||||
<div className="flex items-start justify-between gap-4 mb-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<SectionHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
icon={icon}
|
||||
className={cn(subtitle ? 'mb-0' : 'mb-0')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 shrink-0">
|
||||
{actions}
|
||||
{headline != null && (
|
||||
<div className="text-right">
|
||||
<div className="text-title-lg font-heading font-bold text-krow-blue tabular-nums leading-none">
|
||||
{headline}
|
||||
</div>
|
||||
{headlineLabel && (
|
||||
<div className="text-[10px] uppercase tracking-wide text-ink-4 mt-1">
|
||||
{headlineLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{legend && <ChartLegend items={legend} className="mb-4 mt-3" />}
|
||||
|
||||
<div className={cn('flex-1 min-w-0', !legend && 'mt-4')} style={{ minHeight: height }}>
|
||||
{isEmpty ? (
|
||||
<EmptyState icon={BarChart3} variant="filtered" title="Nothing to chart" description={emptyMessage} size="sm" />
|
||||
) : raw ? (
|
||||
children
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
{children}
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{footer && <div className="mt-4 pt-4 border-t border-border">{footer}</div>}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
309
src/components/ds/DataTable.jsx
Normal file
309
src/components/ds/DataTable.jsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { EmptyState } from '@/components/ds/EmptyState';
|
||||
import { SkeletonTable } from '@/components/ds/Skeleton';
|
||||
import { LoadingOverlay } from '@/components/ds/LoadingState';
|
||||
import { Pagination } from '@/components/ds/Pagination';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
/**
|
||||
* DataTable — the app's one table.
|
||||
*
|
||||
* Columns are declared as data:
|
||||
*
|
||||
* { key, header, accessor?, sortValue?, cell?, align?, width?, sortable?, hideBelow? }
|
||||
*
|
||||
* Sorting, selection and pagination are handled internally by default, but each
|
||||
* can be lifted by passing the corresponding value + handler — so the same
|
||||
* component serves a simple static list and a server-paginated view.
|
||||
*
|
||||
* `hideBelow: 'md'` drops a column on small screens, which is what keeps the
|
||||
* table usable on mobile without a horizontal scroll for every view.
|
||||
*/
|
||||
export function DataTable({
|
||||
columns = [],
|
||||
rows = [],
|
||||
/** Stable row identity. Falls back to `row.id`, then the index. */
|
||||
getRowId = (row, i) => row?.id ?? i,
|
||||
|
||||
loading = false,
|
||||
/** Dims existing rows instead of replacing them — for refetches. */
|
||||
refreshing = false,
|
||||
|
||||
/* Sorting — omit `sort` to let the table own it */
|
||||
sort: controlledSort,
|
||||
onSortChange,
|
||||
defaultSort,
|
||||
|
||||
/* Selection — pass `selectedIds` to enable */
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
|
||||
/* Pagination — pass `pageSize` to enable */
|
||||
pageSize: initialPageSize,
|
||||
page: controlledPage,
|
||||
onPageChange,
|
||||
totalItems,
|
||||
pageSizeOptions,
|
||||
|
||||
onRowClick,
|
||||
emptyState,
|
||||
/** True when filters are active, so the empty copy can say so. */
|
||||
isFiltered = false,
|
||||
onClearFilters,
|
||||
|
||||
caption,
|
||||
className,
|
||||
rowClassName,
|
||||
stickyHeader = false,
|
||||
}) {
|
||||
/* ── Sorting ─────────────────────────────────────────────────────────── */
|
||||
const [internalSort, setInternalSort] = React.useState(defaultSort ?? null);
|
||||
const sort = controlledSort !== undefined ? controlledSort : internalSort;
|
||||
|
||||
const setSort = (next) => {
|
||||
if (onSortChange) onSortChange(next);
|
||||
if (controlledSort === undefined) setInternalSort(next);
|
||||
};
|
||||
|
||||
const toggleSort = (column) => {
|
||||
if (sort?.key !== column.key) return setSort({ key: column.key, direction: 'asc' });
|
||||
if (sort.direction === 'asc') return setSort({ key: column.key, direction: 'desc' });
|
||||
// Third click clears sorting, so the original order is reachable.
|
||||
return setSort(null);
|
||||
};
|
||||
|
||||
const valueFor = (column, row) => {
|
||||
if (column.sortValue) return column.sortValue(row);
|
||||
if (column.accessor) return column.accessor(row);
|
||||
return row?.[column.key];
|
||||
};
|
||||
|
||||
const sortedRows = React.useMemo(() => {
|
||||
// A controlled `sort` means the parent already ordered the rows.
|
||||
if (!sort || controlledSort !== undefined) return rows;
|
||||
const column = columns.find((c) => c.key === sort.key);
|
||||
if (!column) return rows;
|
||||
|
||||
const factor = sort.direction === 'desc' ? -1 : 1;
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = valueFor(column, a);
|
||||
const bv = valueFor(column, b);
|
||||
if (av == null && bv == null) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor;
|
||||
return String(av).localeCompare(String(bv)) * factor;
|
||||
});
|
||||
}, [rows, sort, columns, controlledSort]);
|
||||
|
||||
/* ── Pagination ──────────────────────────────────────────────────────── */
|
||||
const paginated = initialPageSize != null;
|
||||
const [internalPage, setInternalPage] = React.useState(1);
|
||||
const [pageSize, setPageSize] = React.useState(initialPageSize ?? 25);
|
||||
const page = controlledPage !== undefined ? controlledPage : internalPage;
|
||||
|
||||
const setPage = (next) => {
|
||||
if (onPageChange) onPageChange(next);
|
||||
if (controlledPage === undefined) setInternalPage(next);
|
||||
};
|
||||
|
||||
// Snap back to page 1 whenever the result set changes underneath us,
|
||||
// otherwise filtering to 3 rows while on page 4 shows an empty table.
|
||||
const rowCount = rows.length;
|
||||
React.useEffect(() => {
|
||||
if (controlledPage === undefined) setInternalPage(1);
|
||||
}, [rowCount, sort, controlledPage]);
|
||||
|
||||
const serverPaginated = totalItems != null && onPageChange != null;
|
||||
const pageCount = paginated
|
||||
? Math.max(1, Math.ceil((totalItems ?? sortedRows.length) / pageSize))
|
||||
: 1;
|
||||
|
||||
const visibleRows = React.useMemo(() => {
|
||||
if (!paginated || serverPaginated) return sortedRows;
|
||||
const start = (page - 1) * pageSize;
|
||||
return sortedRows.slice(start, start + pageSize);
|
||||
}, [sortedRows, paginated, serverPaginated, page, pageSize]);
|
||||
|
||||
/* ── Selection ───────────────────────────────────────────────────────── */
|
||||
const selectable = selectedIds !== undefined;
|
||||
const selected = React.useMemo(() => new Set(selectedIds || []), [selectedIds]);
|
||||
const pageIds = visibleRows.map((row, i) => getRowId(row, i));
|
||||
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id));
|
||||
const someOnPageSelected = pageIds.some((id) => selected.has(id));
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allOnPageSelected) {
|
||||
onSelectionChange?.(selectedIds.filter((id) => !pageIds.includes(id)));
|
||||
} else {
|
||||
onSelectionChange?.([...new Set([...selectedIds, ...pageIds])]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRow = (id) => {
|
||||
onSelectionChange?.(
|
||||
selected.has(id) ? selectedIds.filter((s) => s !== id) : [...selectedIds, id]
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Render ──────────────────────────────────────────────────────────── */
|
||||
const hideBelowClass = { sm: 'hidden sm:table-cell', md: 'hidden md:table-cell', lg: 'hidden lg:table-cell' };
|
||||
const alignClass = { right: 'text-right', center: 'text-center' };
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
|
||||
<SkeletonTable rows={initialPageSize && initialPageSize < 8 ? initialPageSize : 6} columns={columns.length} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
if (sortedRows.length === 0) {
|
||||
return (
|
||||
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
|
||||
{emptyState || (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
variant={isFiltered ? 'filtered' : 'empty'}
|
||||
title={isFiltered ? 'No matching results' : 'Nothing here yet'}
|
||||
description={
|
||||
isFiltered
|
||||
? 'No rows match the current filters. Try widening or clearing them.'
|
||||
: 'Rows will appear here once there is data.'
|
||||
}
|
||||
action={isFiltered && onClearFilters ? { label: 'Clear filters', onClick: onClearFilters } : undefined}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<Surface padding="none" radius="xl" className="relative overflow-hidden">
|
||||
<LoadingOverlay show={refreshing} />
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
{caption && <caption className="sr-only">{caption}</caption>}
|
||||
|
||||
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
|
||||
<tr className="bg-surface-subtle border-b border-border">
|
||||
{selectable && (
|
||||
<th scope="col" className="w-10 px-4 py-3">
|
||||
<Checkbox
|
||||
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all rows on this page"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
|
||||
{columns.map((column) => {
|
||||
const active = sort?.key === column.key;
|
||||
const SortIcon = !active ? ChevronsUpDown : sort.direction === 'asc' ? ArrowUp : ArrowDown;
|
||||
|
||||
return (
|
||||
<th
|
||||
key={column.key}
|
||||
scope="col"
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||
className={cn(
|
||||
'px-4 py-3 text-overline uppercase text-ink-4 font-heading whitespace-nowrap',
|
||||
column.align ? alignClass[column.align] : 'text-left',
|
||||
column.hideBelow && hideBelowClass[column.hideBelow]
|
||||
)}
|
||||
>
|
||||
{column.sortable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(column)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-sm transition-colors hover:text-ink-2 focus-ring-inset',
|
||||
active && 'text-krow-blue'
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
<SortIcon className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
column.header
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{visibleRows.map((row, i) => {
|
||||
const id = getRowId(row, i);
|
||||
const isSelected = selected.has(id);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={cn(
|
||||
'border-b border-border last:border-0 transition-colors duration-fast',
|
||||
onRowClick && 'cursor-pointer',
|
||||
isSelected ? 'bg-krow-blue-tint' : 'hover:bg-surface-subtle',
|
||||
typeof rowClassName === 'function' ? rowClassName(row) : rowClassName
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleRow(id)}
|
||||
aria-label={`Select row ${i + 1}`}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'px-4 py-3.5 text-body-sm text-ink-2 align-middle',
|
||||
column.align ? alignClass[column.align] : 'text-left',
|
||||
column.hideBelow && hideBelowClass[column.hideBelow],
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.cell
|
||||
? column.cell(row)
|
||||
: column.accessor
|
||||
? column.accessor(row)
|
||||
: row?.[column.key] ?? '—'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
{paginated && (
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPageChange={setPage}
|
||||
totalItems={totalItems ?? sortedRows.length}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/components/ds/Drawer.jsx
Normal file
75
src/components/ds/Drawer.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
|
||||
const WIDTHS = {
|
||||
sm: 'w-full sm:w-80',
|
||||
default: 'w-full sm:w-[28rem]',
|
||||
lg: 'w-full sm:w-[36rem]',
|
||||
xl: 'w-full sm:w-[44rem]',
|
||||
};
|
||||
|
||||
/**
|
||||
* Drawer — an edge-anchored panel for detail views, filters and mobile nav.
|
||||
*
|
||||
* Same header/body/footer contract as Modal, so the two are interchangeable:
|
||||
* a detail view can be a Drawer on desktop and a bottom sheet on mobile without
|
||||
* its contents changing.
|
||||
*/
|
||||
export function Drawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
side = 'right',
|
||||
size = 'default',
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
/** Hides the header for a fully custom panel (the mobile nav does this). */
|
||||
bare = false,
|
||||
}) {
|
||||
const isVertical = side === 'top' || side === 'bottom';
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side={side}
|
||||
className={cn(
|
||||
'p-0 gap-0 flex flex-col',
|
||||
isVertical ? 'h-auto max-h-[85vh]' : WIDTHS[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!bare && (
|
||||
<SheetHeader className="px-5 pt-5 pb-4 border-b border-border shrink-0 space-y-0 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && (
|
||||
<span className="grid place-items-center w-9 h-9 rounded-xl bg-krow-blue-tint text-krow-blue shrink-0">
|
||||
<Icon className="w-4.5 h-4.5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="text-title font-heading text-ink-1">{title}</SheetTitle>
|
||||
{description && (
|
||||
<SheetDescription className="text-body-sm text-ink-3 mt-1">
|
||||
{description}
|
||||
</SheetDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<div className="px-5 py-4 border-t border-border bg-surface-subtle shrink-0 flex items-center justify-end gap-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
75
src/components/ds/EmptyState.jsx
Normal file
75
src/components/ds/EmptyState.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* EmptyState — what a region shows when it has nothing to show.
|
||||
*
|
||||
* Deliberately distinguishes two situations that need different copy and
|
||||
* different actions:
|
||||
*
|
||||
* - `empty` — nothing exists yet. Offer the action that creates the first one.
|
||||
* - `filtered` — things exist but none match. Offer to clear the filters.
|
||||
*
|
||||
* Pages that conflate them end up telling a user with 22 candidates that they
|
||||
* have no candidates.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
/** Primary action — a <Button>, or `{ label, onClick }`. */
|
||||
action,
|
||||
secondaryAction,
|
||||
variant = 'empty',
|
||||
size = 'default',
|
||||
className,
|
||||
}) {
|
||||
const renderAction = (spec, buttonVariant) => {
|
||||
if (!spec) return null;
|
||||
if (React.isValidElement(spec)) return spec;
|
||||
return (
|
||||
<Button variant={buttonVariant} size="sm" onClick={spec.onClick}>
|
||||
{spec.icon && <spec.icon aria-hidden="true" />}
|
||||
{spec.label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center text-center',
|
||||
size === 'sm' ? 'py-8 px-4' : size === 'lg' ? 'py-20 px-6' : 'py-14 px-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center rounded-full mb-4',
|
||||
variant === 'filtered' ? 'bg-surface-sunken text-ink-4' : 'bg-krow-blue-tint text-krow-blue',
|
||||
size === 'sm' ? 'w-10 h-10' : 'w-14 h-14'
|
||||
)}
|
||||
>
|
||||
<Icon className={size === 'sm' ? 'w-5 h-5' : 'w-6 h-6'} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h3 className={cn('font-heading text-ink-1', size === 'sm' ? 'text-body' : 'text-title')}>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{description && (
|
||||
<p className="text-body-sm text-ink-3 mt-1.5 max-w-sm leading-relaxed">{description}</p>
|
||||
)}
|
||||
|
||||
{(action || secondaryAction) && (
|
||||
<div className="flex items-center gap-2 mt-5">
|
||||
{renderAction(action, 'default')}
|
||||
{renderAction(secondaryAction, 'outline')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/components/ds/Field.jsx
Normal file
68
src/components/ds/Field.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Field — the label / hint / error scaffolding every form control shares.
|
||||
*
|
||||
* Controls stay presentational and Field owns the surrounding text, so label
|
||||
* spacing, required markers and error styling are identical across the app.
|
||||
* It wires `id`, `aria-describedby` and `aria-invalid` onto its child.
|
||||
*/
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
htmlFor,
|
||||
/** Lays the label beside the control instead of above it (toggles, checkboxes). */
|
||||
inline = false,
|
||||
className,
|
||||
children,
|
||||
}) {
|
||||
const reactId = React.useId();
|
||||
const id = htmlFor || reactId;
|
||||
const hintId = hint ? `${id}-hint` : undefined;
|
||||
const errorId = error ? `${id}-error` : undefined;
|
||||
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
|
||||
|
||||
// Only clone when the child is a single element that can accept the wiring.
|
||||
const control = React.isValidElement(children)
|
||||
? React.cloneElement(children, {
|
||||
id: children.props.id || id,
|
||||
'aria-describedby': children.props['aria-describedby'] || describedBy,
|
||||
'aria-invalid': error ? true : children.props['aria-invalid'],
|
||||
})
|
||||
: children;
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<div className={cn('flex items-start gap-3', className)}>
|
||||
{control}
|
||||
<div className="min-w-0">
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-body-sm font-medium text-ink-2 cursor-pointer">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{hint && !error && <p id={hintId} className="text-caption text-ink-3 mt-0.5">{hint}</p>}
|
||||
{error && <p id={errorId} className="text-caption text-destructive mt-0.5">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
{label && (
|
||||
<label htmlFor={id} className="block text-body-sm font-medium text-ink-2">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{control}
|
||||
{hint && !error && <p id={hintId} className="text-caption text-ink-3">{hint}</p>}
|
||||
{error && <p id={errorId} className="text-caption text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/components/ds/FilterBar.jsx
Normal file
189
src/components/ds/FilterBar.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import * as React from 'react';
|
||||
import { SlidersHorizontal, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SearchInput } from '@/components/ds/SearchInput';
|
||||
import { MultiSelect } from '@/components/ds/MultiSelect';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
/**
|
||||
* FilterBar — the search + filters row above a list, table or grid.
|
||||
*
|
||||
* Filters are declared as data so every list page gets the same layout,
|
||||
* the same responsive behaviour, and the same "Clear all" affordance:
|
||||
*
|
||||
* { key, label, type: 'select' | 'multi', options, placeholder, width }
|
||||
*
|
||||
* On mobile the filters collapse behind a "Filters" toggle with an active count,
|
||||
* because four side-by-side selects are unusable at 375px.
|
||||
*/
|
||||
export function FilterBar({
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Search...',
|
||||
|
||||
filters = [],
|
||||
/** `{ [key]: string | string[] }` */
|
||||
values = {},
|
||||
onChange,
|
||||
|
||||
/** Right-aligned actions — primary buttons, view switchers. */
|
||||
actions,
|
||||
/** Rendered under the bar, e.g. a result count. */
|
||||
meta,
|
||||
|
||||
/** Value that means "no filter" for select-type filters. */
|
||||
allValue = 'all',
|
||||
className,
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const isActive = (filter) => {
|
||||
const value = values[filter.key];
|
||||
if (filter.type === 'multi') return Array.isArray(value) && value.length > 0;
|
||||
return value != null && value !== allValue && value !== '';
|
||||
};
|
||||
|
||||
const activeFilters = filters.filter(isActive);
|
||||
const activeCount = activeFilters.length + (search ? 1 : 0);
|
||||
|
||||
const set = (key, value) => onChange?.({ ...values, [key]: value });
|
||||
|
||||
const clearAll = () => {
|
||||
const cleared = {};
|
||||
filters.forEach((f) => {
|
||||
cleared[f.key] = f.type === 'multi' ? [] : allValue;
|
||||
});
|
||||
onChange?.(cleared);
|
||||
onSearchChange?.('');
|
||||
};
|
||||
|
||||
const clearOne = (filter) => set(filter.key, filter.type === 'multi' ? [] : allValue);
|
||||
|
||||
const labelFor = (filter) => {
|
||||
const value = values[filter.key];
|
||||
if (filter.type === 'multi') {
|
||||
return `${filter.label}: ${value.length}`;
|
||||
}
|
||||
const option = (filter.options || [])
|
||||
.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
|
||||
.find((o) => o.value === value);
|
||||
return `${filter.label}: ${option?.label ?? value}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||
{onSearchChange && (
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
containerClassName="flex-1 min-w-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile: collapse the filter controls behind a toggle. */}
|
||||
{filters.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
shape="rounded"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="sm:hidden justify-between"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<SlidersHorizontal aria-hidden="true" />
|
||||
Filters
|
||||
</span>
|
||||
{activeFilters.length > 0 && (
|
||||
<Badge variant="default" size="sm">{activeFilters.length}</Badge>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col sm:flex-row gap-3 sm:items-center',
|
||||
expanded ? 'flex' : 'hidden sm:flex'
|
||||
)}
|
||||
>
|
||||
{filters.map((filter) => {
|
||||
const value = values[filter.key];
|
||||
|
||||
if (filter.type === 'multi') {
|
||||
return (
|
||||
<MultiSelect
|
||||
key={filter.key}
|
||||
options={filter.options}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={(next) => set(filter.key, next)}
|
||||
placeholder={filter.placeholder || filter.label}
|
||||
className={cn('sm:w-auto', filter.width || 'sm:min-w-44')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const options = (filter.options || []).map((o) =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={filter.key}
|
||||
value={value ?? allValue}
|
||||
onValueChange={(next) => set(filter.key, next)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={filter.label}
|
||||
className={cn('sm:w-auto', filter.width || 'sm:min-w-40')}
|
||||
>
|
||||
<SelectValue placeholder={filter.placeholder || filter.label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{actions && <div className="flex items-center gap-2 sm:ml-auto shrink-0">{actions}</div>}
|
||||
</div>
|
||||
|
||||
{/* Active filter chips — the escape hatch when a list looks wrong and the
|
||||
user cannot see why. */}
|
||||
{(activeCount > 0 || meta) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{meta && <span className="text-caption text-ink-3 mr-1">{meta}</span>}
|
||||
|
||||
{activeFilters.map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
onClick={() => clearOne(filter)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-krow-blue-tint px-2.5 py-1 text-caption font-medium text-krow-blue hover:bg-[#DBEAFE] transition-colors focus-ring"
|
||||
>
|
||||
{labelFor(filter)}
|
||||
<X className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{activeCount > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
className="text-caption font-medium text-ink-3 hover:text-ink-1 hover:underline transition-colors focus-ring rounded-sm"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/ds/IconButton.jsx
Normal file
39
src/components/ds/IconButton.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
/**
|
||||
* IconButton — a square Button carrying only an icon.
|
||||
*
|
||||
* It delegates entirely to Button so the two can never diverge, and requires a
|
||||
* `label`: an icon-only control with no accessible name is a bug, so the API
|
||||
* makes it impossible to omit. The label doubles as the tooltip.
|
||||
*/
|
||||
export const IconButton = React.forwardRef(
|
||||
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
|
||||
const sizeMap = {
|
||||
xs: 'icon-xs',
|
||||
sm: 'icon-sm',
|
||||
default: 'icon',
|
||||
lg: 'icon-lg',
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button ref={ref} size={sizeMap[size] || 'icon'} aria-label={label} {...props}>
|
||||
<Icon aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!tooltip) return button;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={side}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
IconButton.displayName = 'IconButton';
|
||||
177
src/components/ds/KpiCard.jsx
Normal file
177
src/components/ds/KpiCard.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { Skeleton } from '@/components/ds/Skeleton';
|
||||
|
||||
const TONES = {
|
||||
brand: { tile: 'bg-krow-blue text-white', glow: 'shadow-brand', value: 'text-krow-blue' },
|
||||
navy: { tile: 'bg-krow-navy text-white', glow: 'shadow-md', value: 'text-krow-blue' },
|
||||
accent: { tile: 'bg-krow-yellow text-krow-blue', glow: 'shadow-[0_0_24px_rgba(249,229,71,0.35)]', value: 'text-krow-blue' },
|
||||
success: { tile: 'bg-success-muted text-success', glow: 'shadow-xs', value: 'text-ink-1' },
|
||||
warning: { tile: 'bg-warning-muted text-warning', glow: 'shadow-xs', value: 'text-ink-1' },
|
||||
neutral: { tile: 'bg-surface-sunken text-ink-3', glow: 'shadow-xs', value: 'text-ink-1' },
|
||||
};
|
||||
|
||||
/**
|
||||
* KpiCard — one headline number in a dashboard row.
|
||||
*
|
||||
* Deliberately minimal: icon tile, value, label. When a number needs context
|
||||
* (a target, a breakdown, a sparkline) use `MetricCard`; keeping the two
|
||||
* separate stops KPI rows from drifting into inconsistent heights.
|
||||
*/
|
||||
export function KpiCard({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
tone = 'brand',
|
||||
/** Stagger position in a row — drives the entrance delay. */
|
||||
index = 0,
|
||||
loading = false,
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
const t = TONES[tone] || TONES.brand;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Surface radius="xl" padding="sm" className={className}>
|
||||
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
|
||||
<Skeleton className="h-7 w-14 mb-2" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.06, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
whileHover={{ y: -4, transition: { duration: 0.2 } }}
|
||||
>
|
||||
<Surface
|
||||
as={onClick ? 'button' : 'div'}
|
||||
onClick={onClick}
|
||||
interactive={onClick ? true : undefined}
|
||||
radius="xl"
|
||||
padding="sm"
|
||||
className={cn('w-full text-left hover:shadow-lg', className)}
|
||||
>
|
||||
{Icon && (
|
||||
<div className={cn('grid place-items-center w-10 h-10 rounded-xl mb-3', t.tile, t.glow)}>
|
||||
<Icon className="w-5 h-5" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyed so the number re-animates when it changes, which draws the eye
|
||||
to the figure that actually moved. */}
|
||||
<motion.div
|
||||
key={value}
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: index * 0.06 + 0.2, type: 'spring', stiffness: 200 }}
|
||||
className={cn('text-display font-heading font-bold tabular-nums', t.value)}
|
||||
>
|
||||
{value}
|
||||
</motion.div>
|
||||
|
||||
<div className="text-overline text-ink-4 mt-0.5 normal-case tracking-wide">{label}</div>
|
||||
</Surface>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formats a delta as a signed percentage: 12 → "+12%". */
|
||||
const formatDelta = (delta, suffix = '%') =>
|
||||
`${delta > 0 ? '+' : ''}${delta}${suffix}`;
|
||||
|
||||
/**
|
||||
* MetricCard — a number with supporting context: a trend, a target, a
|
||||
* sub-caption, or a small chart.
|
||||
*
|
||||
* `invertTrend` exists because "up" is not always good — time-to-hire rising is
|
||||
* bad, and colouring it green would be actively misleading.
|
||||
*/
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
/** Signed number; sign drives the arrow and colour. */
|
||||
delta,
|
||||
deltaSuffix = '%',
|
||||
deltaLabel,
|
||||
invertTrend = false,
|
||||
sub,
|
||||
icon: Icon,
|
||||
tone = 'neutral',
|
||||
/** A sparkline or mini chart, rendered under the value. */
|
||||
chart,
|
||||
loading = false,
|
||||
onClick,
|
||||
className,
|
||||
}) {
|
||||
const t = TONES[tone] || TONES.neutral;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Surface radius="xl" padding="default" className={className}>
|
||||
<Skeleton className="h-2.5 w-24 mb-3" />
|
||||
<Skeleton className="h-8 w-20 mb-2" />
|
||||
<Skeleton className="h-2.5 w-32" />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
const hasDelta = typeof delta === 'number' && delta !== 0;
|
||||
const isGood = invertTrend ? delta < 0 : delta > 0;
|
||||
const TrendIcon = !hasDelta ? Minus : delta > 0 ? ArrowUpRight : ArrowDownRight;
|
||||
|
||||
return (
|
||||
<Surface
|
||||
as={onClick ? 'button' : 'div'}
|
||||
onClick={onClick}
|
||||
interactive={onClick ? true : undefined}
|
||||
radius="xl"
|
||||
padding="default"
|
||||
className={cn('w-full text-left', className)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<p className="text-body-sm text-ink-3 font-medium">{label}</p>
|
||||
{Icon && (
|
||||
<span className={cn('grid place-items-center w-8 h-8 rounded-lg shrink-0', t.tile)}>
|
||||
<Icon className="w-4 h-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2.5 flex-wrap">
|
||||
<span className="text-display font-heading font-bold text-ink-1 tabular-nums leading-none">
|
||||
{value}
|
||||
</span>
|
||||
|
||||
{(hasDelta || delta === 0) && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 text-caption font-semibold rounded-full px-1.5 py-0.5 mb-0.5',
|
||||
!hasDelta
|
||||
? 'bg-neutral-muted text-ink-3'
|
||||
: isGood
|
||||
? 'bg-success-muted text-success'
|
||||
: 'bg-destructive-muted text-destructive'
|
||||
)}
|
||||
>
|
||||
<TrendIcon className="w-3 h-3" aria-hidden="true" />
|
||||
{hasDelta ? formatDelta(delta, deltaSuffix) : 'No change'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(deltaLabel || sub) && (
|
||||
<p className="text-caption text-ink-4 mt-2">{deltaLabel || sub}</p>
|
||||
)}
|
||||
|
||||
{chart && <div className="mt-4">{chart}</div>}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
78
src/components/ds/Layout.jsx
Normal file
78
src/components/ds/Layout.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Layout helpers.
|
||||
*
|
||||
* These exist so vertical rhythm and grid behaviour are declared once. The
|
||||
* reference pages each invented their own spacing (`space-y-5`, `space-y-6`,
|
||||
* `space-y-8` on sibling pages), which is exactly the drift a design system is
|
||||
* supposed to prevent.
|
||||
*/
|
||||
|
||||
const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' };
|
||||
|
||||
/**
|
||||
* Grid — responsive column layouts with the app's standard breakpoints.
|
||||
*
|
||||
* `cols` is the count at the largest breakpoint; the intermediate steps are
|
||||
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
|
||||
*/
|
||||
export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
|
||||
const columns = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 sm:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5',
|
||||
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6',
|
||||
}[cols];
|
||||
|
||||
return (
|
||||
<div className={cn('grid', columns, GAPS[gap], className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
|
||||
export function Stack({ space = 'default', className, children, ...props }) {
|
||||
const spacing = {
|
||||
xs: 'space-y-2',
|
||||
sm: 'space-y-3',
|
||||
default: 'space-y-5',
|
||||
lg: 'space-y-6',
|
||||
xl: 'space-y-8',
|
||||
}[space];
|
||||
|
||||
return (
|
||||
<div className={cn(spacing, className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PageSection — a titled region of a page. Pairs a SectionHeader-style label
|
||||
* with its content at consistent spacing.
|
||||
*/
|
||||
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
|
||||
return (
|
||||
<section className={cn('space-y-3', className)}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{level === 'overline' ? (
|
||||
<h2 className="text-overline uppercase text-ink-4 font-heading">{title}</h2>
|
||||
) : (
|
||||
<div>
|
||||
<h2 className="text-title-lg font-heading text-ink-1">{title}</h2>
|
||||
{subtitle && <p className="text-body-sm text-ink-3 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
74
src/components/ds/LoadingState.jsx
Normal file
74
src/components/ds/LoadingState.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Spinner — the only spinner in the system.
|
||||
*/
|
||||
export function Spinner({ size = 'default', className, label = 'Loading' }) {
|
||||
const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' };
|
||||
return (
|
||||
<Loader2
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={cn('animate-spin text-ink-4', sizes[size] || sizes.default, className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LoadingState — a centred spinner with optional copy, for a region whose
|
||||
* shape is not known ahead of time.
|
||||
*
|
||||
* Prefer `Skeleton*` when the shape *is* known: a skeleton that matches the
|
||||
* final layout prevents the content jump a spinner always causes.
|
||||
*/
|
||||
export function LoadingState({ message, size = 'default', className }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3',
|
||||
size === 'sm' ? 'py-8' : size === 'lg' ? 'py-24' : 'py-16',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Spinner size={size === 'sm' ? 'default' : 'lg'} />
|
||||
{message && <p className="text-body-sm text-ink-3">{message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* InlineLoading — a spinner and label on one line, for inside buttons rows,
|
||||
* card headers, or beside a control that is refreshing.
|
||||
*/
|
||||
export function InlineLoading({ message = 'Loading…', className }) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
|
||||
<Spinner size="sm" />
|
||||
{message}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LoadingOverlay — covers content that is being replaced, keeping layout
|
||||
* height stable so the page does not jump. Used by DataTable when refetching.
|
||||
*/
|
||||
export function LoadingOverlay({ show, message, className }) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-10 grid place-items-center rounded-[inherit]',
|
||||
'bg-white/60 backdrop-blur-[2px] animate-fade-in',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Spinner />
|
||||
{message && <p className="text-caption text-ink-3">{message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/components/ds/Modal.jsx
Normal file
120
src/components/ds/Modal.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const WIDTHS = {
|
||||
sm: 'sm:max-w-sm',
|
||||
default: 'sm:max-w-lg',
|
||||
lg: 'sm:max-w-2xl',
|
||||
xl: 'sm:max-w-4xl',
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal — the standard dialog shape: header, scrollable body, footer.
|
||||
*
|
||||
* Owning the scroll boundary here is the point. A dialog that lets its whole
|
||||
* body scroll pushes the title and the confirm button off-screen on a laptop;
|
||||
* this keeps both pinned and scrolls only the middle.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
size = 'default',
|
||||
/** Footer content. Omit for a dialog whose body carries its own actions. */
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
/** Blocks closing — use while a submit is in flight. */
|
||||
busy = false,
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn('p-0 gap-0 overflow-hidden max-h-[90vh] flex flex-col', WIDTHS[size], className)}
|
||||
onInteractOutside={busy ? (e) => e.preventDefault() : undefined}
|
||||
onEscapeKeyDown={busy ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border shrink-0 space-y-0">
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && (
|
||||
<span className="grid place-items-center w-9 h-9 rounded-xl bg-krow-blue-tint text-krow-blue shrink-0">
|
||||
<Icon className="w-4.5 h-4.5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 text-left">
|
||||
<DialogTitle className="text-title font-heading text-ink-1">{title}</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription className="text-body-sm text-ink-3 mt-1">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<DialogFooter className="px-6 py-4 border-t border-border bg-surface-subtle shrink-0">
|
||||
{footer}
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfirmModal — a destructive or irreversible confirmation.
|
||||
*
|
||||
* Defaults to the destructive tone because that is what confirmation is almost
|
||||
* always for, and it labels the action with the verb ("Delete") rather than
|
||||
* "OK", so the button says what will happen.
|
||||
*/
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
title = 'Are you sure?',
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
tone = 'destructive',
|
||||
icon,
|
||||
busy = false,
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
description={description}
|
||||
icon={icon}
|
||||
size="sm"
|
||||
busy={busy}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange?.(false)} disabled={busy}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={tone === 'destructive' ? 'destructive' : 'default'} onClick={onConfirm} loading={busy}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
171
src/components/ds/MultiSelect.jsx
Normal file
171
src/components/ds/MultiSelect.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronDown, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SearchInput } from '@/components/ds/SearchInput';
|
||||
|
||||
/**
|
||||
* MultiSelect — pick several values from a list.
|
||||
*
|
||||
* Selections render as removable chips in the trigger. Above `searchThreshold`
|
||||
* options a filter field appears, so short lists stay uncluttered and long ones
|
||||
* stay usable. Options may be strings or `{ value, label, count }`.
|
||||
*/
|
||||
export function MultiSelect({
|
||||
options = [],
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = 'Select...',
|
||||
/** Chips shown before collapsing into "+N more". */
|
||||
maxChips = 2,
|
||||
searchThreshold = 8,
|
||||
disabled,
|
||||
className,
|
||||
align = 'start',
|
||||
emptyMessage = 'No options',
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
|
||||
const normalized = React.useMemo(
|
||||
() => options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o)),
|
||||
[options]
|
||||
);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!query) return normalized;
|
||||
const q = query.toLowerCase();
|
||||
return normalized.filter((o) => o.label.toLowerCase().includes(q));
|
||||
}, [normalized, query]);
|
||||
|
||||
const labelFor = (v) => normalized.find((o) => o.value === v)?.label ?? v;
|
||||
|
||||
const toggle = (optionValue) => {
|
||||
const next = value.includes(optionValue)
|
||||
? value.filter((v) => v !== optionValue)
|
||||
: [...value, optionValue];
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
const remove = (optionValue, event) => {
|
||||
// The chip's X sits inside the trigger — don't open the popover.
|
||||
event.stopPropagation();
|
||||
onChange?.(value.filter((v) => v !== optionValue));
|
||||
};
|
||||
|
||||
const visible = value.slice(0, maxChips);
|
||||
const overflow = value.length - visible.length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex min-h-10 w-full items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-left shadow-xs',
|
||||
'transition-all duration-base ease-out',
|
||||
'outline-none focus-visible:border-krow-blue focus-visible:ring-2 focus-visible:ring-krow-blue/20',
|
||||
'disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:opacity-60',
|
||||
open && 'border-krow-blue ring-2 ring-krow-blue/20',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-1 flex-wrap items-center gap-1 min-w-0">
|
||||
{value.length === 0 && <span className="text-body-sm text-ink-4">{placeholder}</span>}
|
||||
{visible.map((v) => (
|
||||
<Badge key={v} variant="soft" size="lg" className="max-w-[160px] pr-1">
|
||||
<span className="truncate">{labelFor(v)}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${labelFor(v)}`}
|
||||
onClick={(e) => remove(v, e)}
|
||||
className="grid place-items-center w-3.5 h-3.5 rounded-full hover:bg-krow-blue/20 shrink-0"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" aria-hidden="true" />
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<Badge variant="neutral" size="lg">+{overflow} more</Badge>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-ink-4 shrink-0 transition-transform duration-base',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align={align} className="w-[--radix-popover-trigger-width] min-w-52 p-0">
|
||||
{normalized.length >= searchThreshold && (
|
||||
<div className="p-2 border-b border-border">
|
||||
<SearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Filter options"
|
||||
size="sm"
|
||||
debounce={0}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-64 overflow-y-auto p-1.5">
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-body-sm text-ink-3">{emptyMessage}</p>
|
||||
)}
|
||||
{filtered.map((option) => {
|
||||
const selected = value.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() => toggle(option.value)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-body-sm transition-colors duration-fast',
|
||||
'hover:bg-krow-blue-tint focus-ring-inset',
|
||||
selected ? 'text-krow-blue font-medium' : 'text-ink-2'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center w-4 h-4 rounded border shrink-0 transition-colors duration-fast',
|
||||
selected ? 'bg-krow-blue border-krow-blue text-white' : 'border-border'
|
||||
)}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3" aria-hidden="true" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
{option.count != null && (
|
||||
<span className="text-caption text-ink-4 tabular-nums">{option.count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{value.length > 0 && (
|
||||
<div className="border-t border-border p-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange?.([])}
|
||||
className="w-full rounded-md px-2 py-1.5 text-body-sm text-ink-3 hover:bg-surface-sunken hover:text-ink-1 transition-colors focus-ring-inset"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
87
src/components/ds/PageHeader.jsx
Normal file
87
src/components/ds/PageHeader.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* PageHeader — the top of every page: optional icon, title, subtitle, and a
|
||||
* right-aligned action slot. Owning the entrance animation here is what keeps
|
||||
* page transitions feeling like one product.
|
||||
*/
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
/** Right-aligned actions — buttons, counts, filters. */
|
||||
actions,
|
||||
/** Rendered above the title, e.g. a Breadcrumb. */
|
||||
breadcrumb,
|
||||
/** Rendered below the subtitle, e.g. a Tabs row. */
|
||||
children,
|
||||
/** `display` for dashboard heroes, `title` for standard pages. */
|
||||
size = 'title',
|
||||
animate = true,
|
||||
className,
|
||||
}) {
|
||||
const Wrapper = animate ? motion.div : 'div';
|
||||
const motionProps = animate
|
||||
? {
|
||||
initial: { opacity: 0, y: -12 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] },
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Wrapper {...motionProps} className={cn('space-y-4', className)}>
|
||||
{breadcrumb}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
{Icon && (
|
||||
<span className="grid place-items-center w-10 h-10 rounded-xl bg-krow-blue text-white shadow-brand shrink-0">
|
||||
<Icon className="w-5 h-5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
className={cn(
|
||||
'font-heading text-ink-1 truncate',
|
||||
size === 'display' ? 'text-display' : 'text-title-lg'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="text-body-sm text-ink-3 mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A dashboard-style greeting header where part of the title is emphasized —
|
||||
* "Good day. Let's hire smarter."
|
||||
*/
|
||||
export function HeroHeader({ lead, highlight, subtitle, actions, className }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={cn('flex items-center justify-between flex-wrap gap-4', className)}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-display font-heading text-ink-1">
|
||||
{lead} {highlight && <span className="text-krow-blue">{highlight}</span>}
|
||||
</h1>
|
||||
{subtitle && <p className="text-body text-ink-3 mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
127
src/components/ds/Pagination.jsx
Normal file
127
src/components/ds/Pagination.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* Builds a compact page list with ellipses: 1 … 4 5 6 … 20.
|
||||
*
|
||||
* Always keeps the first page, the last page, and a window around the current
|
||||
* one, so the control's width stays stable as the user pages through.
|
||||
*/
|
||||
export function buildPageRange(current, total, siblings = 1) {
|
||||
const totalSlots = siblings * 2 + 5;
|
||||
if (total <= totalSlots) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
|
||||
const left = Math.max(current - siblings, 1);
|
||||
const right = Math.min(current + siblings, total);
|
||||
const showLeftEllipsis = left > 2;
|
||||
const showRightEllipsis = right < total - 1;
|
||||
|
||||
const pages = [1];
|
||||
if (showLeftEllipsis) pages.push('…');
|
||||
for (let p = Math.max(2, left); p <= Math.min(total - 1, right); p += 1) pages.push(p);
|
||||
if (showRightEllipsis) pages.push('…');
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination — page navigation with an optional result summary and page-size
|
||||
* control. Renders nothing when there is only one page.
|
||||
*/
|
||||
export function Pagination({
|
||||
page = 1,
|
||||
pageCount = 1,
|
||||
onPageChange,
|
||||
/** Total rows across all pages — enables the "Showing x–y of z" summary. */
|
||||
totalItems,
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 25, 50, 100],
|
||||
className,
|
||||
}) {
|
||||
if (pageCount <= 1 && !onPageSizeChange) return null;
|
||||
|
||||
const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null;
|
||||
const to = totalItems != null && pageSize ? Math.min(page * pageSize, totalItems) : null;
|
||||
const range = buildPageRange(page, pageCount);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Pagination"
|
||||
className={cn('flex flex-wrap items-center justify-between gap-3', className)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{from != null && (
|
||||
<p className="text-caption text-ink-3 tabular-nums">
|
||||
Showing <span className="font-medium text-ink-2">{from}–{to}</span> of{' '}
|
||||
<span className="font-medium text-ink-2">{totalItems}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{onPageSizeChange && (
|
||||
<label className="flex items-center gap-1.5 text-caption text-ink-3">
|
||||
<span className="hidden sm:inline">Rows</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
className="h-7 rounded-md border border-border bg-surface pl-2 pr-6 text-caption text-ink-2 focus-ring-inset cursor-pointer"
|
||||
>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
shape="rounded"
|
||||
aria-label="Previous page"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange?.(page - 1)}
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
{range.map((entry, i) =>
|
||||
entry === '…' ? (
|
||||
<span key={`gap-${i}`} className="px-1.5 text-caption text-ink-4" aria-hidden="true">
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
key={entry}
|
||||
variant={entry === page ? 'default' : 'ghost'}
|
||||
size="icon-sm"
|
||||
shape="rounded"
|
||||
aria-label={`Page ${entry}`}
|
||||
aria-current={entry === page ? 'page' : undefined}
|
||||
onClick={() => onPageChange?.(entry)}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{entry}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
shape="rounded"
|
||||
aria-label="Next page"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => onPageChange?.(page + 1)}
|
||||
>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
255
src/components/ds/Progress.jsx
Normal file
255
src/components/ds/Progress.jsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const clamp = (n) => Math.max(0, Math.min(100, Number(n) || 0));
|
||||
|
||||
const TRACK_TONES = {
|
||||
brand: 'bg-krow-blue',
|
||||
navy: 'bg-krow-navy',
|
||||
accent: 'bg-krow-yellow',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
destructive: 'bg-destructive',
|
||||
};
|
||||
|
||||
/**
|
||||
* Score-band colour, shared by every progress component.
|
||||
*
|
||||
* The app scores people constantly, and the band boundaries (80/60/40) are the
|
||||
* same ones the pipeline tiers use — so a 72 is the same colour on a bar, a ring
|
||||
* and a leaderboard row.
|
||||
*/
|
||||
export function toneForScore(score) {
|
||||
const s = clamp(score);
|
||||
if (s >= 80) return 'brand';
|
||||
if (s >= 60) return 'success';
|
||||
if (s >= 40) return 'warning';
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
/**
|
||||
* ProgressBar — a horizontal track.
|
||||
*
|
||||
* `tone="score"` picks the colour from the value, which is what most usages in
|
||||
* this app want.
|
||||
*/
|
||||
export function ProgressBar({
|
||||
value,
|
||||
max = 100,
|
||||
tone = 'brand',
|
||||
size = 'default',
|
||||
label,
|
||||
showValue = false,
|
||||
valueSuffix = '',
|
||||
className,
|
||||
trackClassName,
|
||||
}) {
|
||||
const pct = clamp((Number(value) / max) * 100);
|
||||
const resolvedTone = tone === 'score' ? toneForScore(pct) : tone;
|
||||
|
||||
const heights = { xs: 'h-1', sm: 'h-1.5', default: 'h-2', lg: 'h-2.5' };
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{(label || showValue) && (
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
{label && <span className="text-caption font-medium text-ink-2">{label}</span>}
|
||||
{showValue && (
|
||||
<span className="text-caption font-semibold text-ink-1 tabular-nums">
|
||||
{value}
|
||||
{valueSuffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={clamp(value)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-label={label || undefined}
|
||||
className={cn(
|
||||
'w-full overflow-hidden rounded-full bg-surface-sunken',
|
||||
heights[size] || heights.default,
|
||||
trackClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-slow ease-out',
|
||||
TRACK_TONES[resolvedTone]
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ProgressRing — a circular gauge.
|
||||
*
|
||||
* Supports the app's two scales: 0–100 percentages and the 300–850 career
|
||||
* score, via `min`/`max`, so the KROW ID gauge and a completion ring are the
|
||||
* same component.
|
||||
*/
|
||||
export function ProgressRing({
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
size = 88,
|
||||
strokeWidth = 8,
|
||||
tone = 'brand',
|
||||
/** Big text in the centre. Defaults to the value. */
|
||||
label,
|
||||
/** Small text above the label. */
|
||||
caption,
|
||||
/** Small text below the label. */
|
||||
footnote,
|
||||
className,
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const ratio = Math.max(0, Math.min(1, (Number(value) - min) / (max - min)));
|
||||
const resolvedTone = tone === 'score' ? toneForScore(ratio * 100) : tone;
|
||||
|
||||
const strokeColor = {
|
||||
brand: '#0A39DF',
|
||||
navy: '#333F48',
|
||||
accent: '#F9E547',
|
||||
success: 'hsl(142 71% 36%)',
|
||||
warning: 'hsl(32 95% 44%)',
|
||||
destructive: 'hsl(0 84% 60%)',
|
||||
}[resolvedTone];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative inline-grid place-items-center shrink-0', className)}
|
||||
style={{ width: size, height: size }}
|
||||
role="img"
|
||||
aria-label={`${label ?? value} ${caption ?? ''}`.trim()}
|
||||
>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-surface-sunken"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${ratio * circumference} ${circumference}`}
|
||||
className="transition-[stroke-dasharray] duration-slow ease-out"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center leading-none">
|
||||
{caption && (
|
||||
<span className="text-[8px] uppercase tracking-wide text-ink-4 font-medium">{caption}</span>
|
||||
)}
|
||||
<span
|
||||
className="font-heading font-bold text-ink-1 tabular-nums"
|
||||
style={{ fontSize: Math.max(12, size * 0.24) }}
|
||||
>
|
||||
{label ?? value}
|
||||
</span>
|
||||
{footnote && <span className="text-[8px] text-ink-4 font-medium mt-0.5">{footnote}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ProgressSteps — a linear multi-step indicator for wizards.
|
||||
*
|
||||
* Completed steps show a check rather than their number, so "done" is readable
|
||||
* at a glance without relying on colour.
|
||||
*/
|
||||
export function ProgressSteps({ steps = [], current = 0, className, onStepClick }) {
|
||||
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
|
||||
|
||||
return (
|
||||
<ol className={cn('flex items-center gap-2', className)}>
|
||||
{normalized.map((step, i) => {
|
||||
const done = i < current;
|
||||
const active = i === current;
|
||||
const clickable = onStepClick && i <= current;
|
||||
|
||||
return (
|
||||
<li key={step.label ?? i} className="flex items-center gap-2 flex-1 last:flex-none">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!clickable}
|
||||
onClick={clickable ? () => onStepClick(i) : undefined}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-2 min-w-0',
|
||||
clickable ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center w-6 h-6 rounded-full text-[11px] font-bold shrink-0 transition-colors duration-base',
|
||||
done && 'bg-krow-blue text-white',
|
||||
active && 'bg-krow-blue-tint text-krow-blue ring-2 ring-krow-blue',
|
||||
!done && !active && 'bg-surface-sunken text-ink-4'
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="w-3 h-3" aria-hidden="true" /> : i + 1}
|
||||
</span>
|
||||
{step.label && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-caption font-medium truncate hidden sm:block',
|
||||
active ? 'text-ink-1' : done ? 'text-ink-2' : 'text-ink-4'
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{i < normalized.length - 1 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-px flex-1 min-w-4 transition-colors duration-base',
|
||||
done ? 'bg-krow-blue' : 'bg-border'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ScoreMeter — a labelled bar row used in score breakdowns, where many
|
||||
* dimensions are listed with a weight and a value.
|
||||
*/
|
||||
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm text-ink-2 truncate">{label}</span>
|
||||
<span className="flex items-baseline gap-1.5 shrink-0">
|
||||
{weight != null && <span className="text-[10px] text-ink-4">{weight}% weight</span>}
|
||||
<span className="text-body-sm font-semibold text-ink-1 tabular-nums">{value}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={value} tone={tone} size="sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/components/ds/SearchInput.jsx
Normal file
101
src/components/ds/SearchInput.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { inputVariants } from '@/components/ui/input';
|
||||
|
||||
/**
|
||||
* SearchInput — a text input with a leading search icon and a clear button.
|
||||
*
|
||||
* Debounces `onChange` by default so pages can filter on every keystroke
|
||||
* without re-running expensive work, while the field itself stays instantly
|
||||
* responsive. Pass `debounce={0}` for immediate updates.
|
||||
*/
|
||||
export const SearchInput = React.forwardRef(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search...',
|
||||
debounce = 200,
|
||||
size = 'default',
|
||||
shape = 'rounded',
|
||||
className,
|
||||
containerClassName,
|
||||
onClear,
|
||||
autoFocus,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Local state keeps typing immediate; the debounced value is what escapes.
|
||||
const [local, setLocal] = React.useState(value ?? '');
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
// Follow controlled resets (a Clear all in a FilterBar) without fighting typing.
|
||||
React.useEffect(() => {
|
||||
setLocal((prev) => (value !== undefined && value !== prev ? value : prev));
|
||||
}, [value]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!debounce) return undefined;
|
||||
if (local === (value ?? '')) return undefined;
|
||||
const timer = setTimeout(() => onChangeRef.current?.(local), debounce);
|
||||
return () => clearTimeout(timer);
|
||||
}, [local, debounce, value]);
|
||||
|
||||
const handleChange = (event) => {
|
||||
const next = event.target.value;
|
||||
setLocal(next);
|
||||
if (!debounce) onChange?.(next);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setLocal('');
|
||||
onChange?.('');
|
||||
onClear?.();
|
||||
};
|
||||
|
||||
const iconInset = size === 'sm' ? 'left-2.5' : 'left-3.5';
|
||||
const padLeft = size === 'sm' ? 'pl-8' : 'pl-10';
|
||||
|
||||
return (
|
||||
<div className={cn('relative', containerClassName)}>
|
||||
<Search
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-y-1/2 w-4 h-4 text-ink-4 pointer-events-none',
|
||||
iconInset
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
ref={ref}
|
||||
type="search"
|
||||
role="searchbox"
|
||||
value={local}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
className={cn(
|
||||
inputVariants({ size, shape }),
|
||||
padLeft,
|
||||
'pr-9 [&::-webkit-search-cancel-button]:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{local && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 grid place-items-center w-5 h-5 rounded-full text-ink-4 hover:text-ink-2 hover:bg-surface-sunken transition-colors focus-ring-inset"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
SearchInput.displayName = 'SearchInput';
|
||||
75
src/components/ds/SectionHeader.jsx
Normal file
75
src/components/ds/SectionHeader.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* SectionHeader — the label row above a section or inside a card. Three levels
|
||||
* so the hierarchy below a PageHeader stays predictable:
|
||||
*
|
||||
* - `overline` — a small all-caps group label ("INTERVIEW COMPLETION")
|
||||
* - `card` — a card's own title row
|
||||
* - `section` — a page-level section title
|
||||
*/
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
level = 'card',
|
||||
actions,
|
||||
/** Renders a "View all ›" affordance on the right. */
|
||||
onViewAll,
|
||||
viewAllLabel = 'View all',
|
||||
className,
|
||||
}) {
|
||||
if (level === 'overline') {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between gap-3 mb-3', className)}>
|
||||
<h2 className="text-overline uppercase text-ink-4 font-heading">{title}</h2>
|
||||
{actions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start justify-between gap-3',
|
||||
level === 'section' ? 'mb-4' : 'mb-5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5 min-w-0">
|
||||
{Icon && (
|
||||
<span className="grid place-items-center w-8 h-8 rounded-lg bg-krow-blue-tint text-krow-blue shrink-0">
|
||||
<Icon className="w-4 h-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3
|
||||
className={cn(
|
||||
'font-heading text-ink-1 truncate',
|
||||
level === 'section' ? 'text-title-lg' : 'text-title'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{subtitle && <p className="text-caption text-ink-3 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{actions}
|
||||
{onViewAll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewAll}
|
||||
className="inline-flex items-center gap-1 text-caption font-medium text-krow-blue hover:underline focus-ring rounded-sm"
|
||||
>
|
||||
{viewAllLabel}
|
||||
<ChevronRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/components/ds/Skeleton.jsx
Normal file
140
src/components/ds/Skeleton.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Skeleton — a loading placeholder.
|
||||
*
|
||||
* Uses a travelling shimmer rather than a pulse: on the app's translucent glass
|
||||
* surfaces a pulsing block reads as a rendering glitch, while a shimmer reads as
|
||||
* progress. The shimmer is disabled automatically under
|
||||
* `prefers-reduced-motion` (see index.css).
|
||||
*/
|
||||
export function Skeleton({ className, radius = 'md', ...props }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-surface-sunken',
|
||||
radius === 'full' ? 'rounded-full' : radius === 'lg' ? 'rounded-xl' : 'rounded-md',
|
||||
'after:absolute after:inset-0 after:shimmer',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The composed patterns below exist so pages never hand-assemble skeletons.
|
||||
* Each mirrors the real component's geometry, which is what makes the swap from
|
||||
* loading to loaded invisible.
|
||||
*/
|
||||
|
||||
/** Paragraph placeholder. The last line is short, as real text is. */
|
||||
export function SkeletonText({ lines = 3, className }) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className={cn('h-3', i === lines - 1 && lines > 1 ? 'w-2/3' : 'w-full')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches KpiCard: icon tile, big number, caption. */
|
||||
export function SkeletonKpi({ className }) {
|
||||
return (
|
||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-4 shadow-sm', className)}>
|
||||
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
|
||||
<Skeleton className="h-7 w-14 mb-2" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches a list row: avatar, two lines of text, trailing value. */
|
||||
export function SkeletonRow({ className }) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3 py-3', className)}>
|
||||
<Skeleton className="w-9 h-9" radius="full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<Skeleton className="h-2.5 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches a card in a grid: title, meta, stat row. */
|
||||
export function SkeletonCard({ className }) {
|
||||
return (
|
||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-5 shadow-sm', className)}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Skeleton className="w-10 h-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-2.5 w-28 mb-4" />
|
||||
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-border">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-5 w-8" />
|
||||
<Skeleton className="h-2 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches DataTable: header row plus n body rows. */
|
||||
export function SkeletonTable({ rows = 5, columns = 4, className }) {
|
||||
return (
|
||||
<div className={cn('overflow-hidden', className)} role="status" aria-label="Loading table">
|
||||
<div className="flex items-center gap-4 px-4 py-3 border-b border-border bg-surface-subtle">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<Skeleton key={i} className={cn('h-2.5', i === 0 ? 'w-32' : 'flex-1 max-w-24')} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div key={r} className="flex items-center gap-4 px-4 py-3.5 border-b border-border last:border-0">
|
||||
{Array.from({ length: columns }).map((_, c) => (
|
||||
<Skeleton key={c} className={cn('h-3', c === 0 ? 'w-32' : 'flex-1 max-w-24')} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches ChartContainer: title, legend, plot area. */
|
||||
export function SkeletonChart({ className, height = 240 }) {
|
||||
return (
|
||||
<div className={cn('glass-card border border-white/60 rounded-2xl p-6 shadow-sm', className)}>
|
||||
<Skeleton className="h-3.5 w-40 mb-2" />
|
||||
<Skeleton className="h-2.5 w-56 mb-6" />
|
||||
<div className="flex items-end gap-3" style={{ height }}>
|
||||
{[0.5, 0.8, 0.35, 0.95, 0.6, 0.75].map((h, i) => (
|
||||
<Skeleton key={i} className="flex-1" style={{ height: `${h * 100}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** n SkeletonRows inside a surface — the common "loading a list" case. */
|
||||
export function SkeletonList({ rows = 5, className }) {
|
||||
return (
|
||||
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<SkeletonRow key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/components/ds/StatusBadge.jsx
Normal file
96
src/components/ds/StatusBadge.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
/**
|
||||
* The domain's states, mapped to a tone and a human label in one place.
|
||||
*
|
||||
* This map is the reason StatusBadge exists: `ai_screened` appeared with three
|
||||
* different colours and two different spellings across the reference pages.
|
||||
* Adding a state here makes it correct everywhere at once.
|
||||
*/
|
||||
export const STATUS_MAP = {
|
||||
/* Application lifecycle */
|
||||
applied: { label: 'Applied', tone: 'neutral' },
|
||||
ai_screened: { label: 'AI Screened', tone: 'info' },
|
||||
shortlisted: { label: 'Shortlisted', tone: 'accent' },
|
||||
interview: { label: 'Interviewing', tone: 'warning' },
|
||||
hired: { label: 'Hired', tone: 'success' },
|
||||
rejected: { label: 'Declined', tone: 'destructive' },
|
||||
|
||||
/* Job posting status */
|
||||
active: { label: 'Hiring', tone: 'default' },
|
||||
draft: { label: 'Draft', tone: 'neutral' },
|
||||
paused: { label: 'Paused', tone: 'warning' },
|
||||
closed: { label: 'Closed', tone: 'destructive' },
|
||||
|
||||
/* Staff status */
|
||||
onboarding: { label: 'Onboarding', tone: 'warning' },
|
||||
inactive: { label: 'Inactive', tone: 'neutral' },
|
||||
|
||||
/* Score bands — the same vocabulary talentHome.js uses */
|
||||
elite: { label: 'Elite', tone: 'default' },
|
||||
excellent: { label: 'Excellent', tone: 'success' },
|
||||
solid: { label: 'Solid', tone: 'info' },
|
||||
building: { label: 'Building', tone: 'warning' },
|
||||
new: { label: 'New', tone: 'neutral' },
|
||||
no_score: { label: 'No score yet', tone: 'neutral' },
|
||||
|
||||
/* Interview verdicts */
|
||||
hire: { label: 'Hire', tone: 'success' },
|
||||
maybe: { label: 'Maybe', tone: 'warning' },
|
||||
no: { label: 'No', tone: 'destructive' },
|
||||
|
||||
/* Evidence verdicts */
|
||||
verified: { label: 'Verified', tone: 'success' },
|
||||
needs_work: { label: 'Needs work', tone: 'warning' },
|
||||
failed: { label: 'Failed', tone: 'destructive' },
|
||||
pending: { label: 'Pending', tone: 'neutral' },
|
||||
expired: { label: 'Expired', tone: 'destructive' },
|
||||
|
||||
/* Profile tiers */
|
||||
Skilled: { label: 'Skilled', tone: 'default' },
|
||||
'Cross-Trained': { label: 'Cross-Trained', tone: 'info' },
|
||||
Beginner: { label: 'Beginner', tone: 'neutral' },
|
||||
};
|
||||
|
||||
/** Title-cases an unknown status so it never renders as a raw enum. */
|
||||
const humanize = (status) =>
|
||||
String(status).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const DOT_COLOR = {
|
||||
default: 'bg-krow-blue',
|
||||
soft: 'bg-krow-blue',
|
||||
accent: 'bg-krow-blue',
|
||||
navy: 'bg-krow-navy',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
destructive: 'bg-destructive',
|
||||
info: 'bg-info',
|
||||
neutral: 'bg-ink-4',
|
||||
outline: 'bg-ink-4',
|
||||
};
|
||||
|
||||
/**
|
||||
* StatusBadge — renders a domain status with its canonical colour and wording.
|
||||
*
|
||||
* `dot` adds a leading indicator for dense rows where colour alone is doing too
|
||||
* much work; it also gives colour-blind users a second cue alongside the label.
|
||||
*/
|
||||
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }) {
|
||||
const entry = STATUS_MAP[status] || {};
|
||||
const resolvedTone = tone || entry.tone || 'neutral';
|
||||
const resolvedLabel = label || entry.label || humanize(status);
|
||||
|
||||
return (
|
||||
<Badge variant={resolvedTone} size={size} className={cn('gap-1.5', className)} {...props}>
|
||||
{dot && (
|
||||
<span
|
||||
className={cn('w-1.5 h-1.5 rounded-full shrink-0', DOT_COLOR[resolvedTone])}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{resolvedLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
72
src/components/ds/Surface.jsx
Normal file
72
src/components/ds/Surface.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Surface — the canonical container in the design system.
|
||||
*
|
||||
* Every card, panel and boxed region in the app is a Surface, so radius,
|
||||
* border, elevation and hover behaviour can only be defined in one place.
|
||||
* `glass` is the default because the app sits on a gradient-mesh background
|
||||
* that solid white cards flatten.
|
||||
*/
|
||||
const surfaceVariants = cva(
|
||||
'transition-all duration-base ease-out',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
glass: 'glass-card border border-white/60',
|
||||
solid: 'bg-surface border border-border',
|
||||
subtle: 'bg-surface-subtle border border-border',
|
||||
sunken: 'bg-surface-sunken border border-transparent',
|
||||
outline: 'bg-transparent border border-border',
|
||||
/* The one dark surface — the Owliver panel */
|
||||
panel: 'panel-surface border border-panel-border text-panel-foreground',
|
||||
},
|
||||
elevation: {
|
||||
none: 'shadow-none',
|
||||
xs: 'shadow-xs',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
xl: 'shadow-xl',
|
||||
},
|
||||
radius: {
|
||||
md: 'rounded-lg',
|
||||
lg: 'rounded-xl',
|
||||
xl: 'rounded-2xl',
|
||||
},
|
||||
padding: {
|
||||
none: 'p-0',
|
||||
sm: 'p-4',
|
||||
default: 'p-5',
|
||||
lg: 'p-6',
|
||||
},
|
||||
interactive: {
|
||||
true: 'cursor-pointer hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF]/60 focus-visible:ring-offset-2',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'glass',
|
||||
elevation: 'sm',
|
||||
radius: 'xl',
|
||||
padding: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Surface = React.forwardRef(
|
||||
({ className, variant, elevation, radius, padding, interactive, as, ...props }, ref) => {
|
||||
const Comp = as || 'div';
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(surfaceVariants({ variant, elevation, radius, padding, interactive }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Surface.displayName = 'Surface';
|
||||
|
||||
export { Surface, surfaceVariants };
|
||||
119
src/components/ds/Tabs.jsx
Normal file
119
src/components/ds/Tabs.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
/**
|
||||
* Tabs — switches between views of the same page.
|
||||
*
|
||||
* Two visual styles:
|
||||
* - `pill` — matches the app's header navigation, with a sliding indicator
|
||||
* - `underline` — quieter, for tabs inside a card
|
||||
*
|
||||
* The sliding indicator uses a shared `layoutId`, so the highlight animates
|
||||
* between tabs the same way the main nav pill does. `layoutGroupId` must be
|
||||
* unique when two tab sets are on screen at once, otherwise the indicator
|
||||
* animates between them.
|
||||
*/
|
||||
export function Tabs({
|
||||
tabs = [],
|
||||
value,
|
||||
onChange,
|
||||
variant = 'pill',
|
||||
size = 'default',
|
||||
layoutGroupId,
|
||||
className,
|
||||
ariaLabel = 'Views',
|
||||
}) {
|
||||
const normalized = tabs.map((t) => (typeof t === 'string' ? { value: t, label: t } : t));
|
||||
const fallbackId = React.useId();
|
||||
const groupId = layoutGroupId || fallbackId;
|
||||
|
||||
if (variant === 'underline') {
|
||||
return (
|
||||
<div role="tablist" aria-label={ariaLabel} className={cn('flex items-center gap-1 border-b border-border', className)}>
|
||||
{normalized.map((tab) => {
|
||||
const active = tab.value === value;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onChange?.(tab.value)}
|
||||
className={cn(
|
||||
'relative inline-flex items-center gap-2 px-3 pb-2.5 pt-1.5 font-medium transition-colors duration-base focus-ring-inset rounded-t-md',
|
||||
size === 'sm' ? 'text-body-sm' : 'text-body',
|
||||
active ? 'text-krow-blue' : 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
{tab.icon && <tab.icon className="w-4 h-4" aria-hidden="true" />}
|
||||
{tab.label}
|
||||
{tab.count != null && (
|
||||
<Badge variant={active ? 'soft' : 'neutral'} size="sm">{tab.count}</Badge>
|
||||
)}
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={`tab-underline-${groupId}`}
|
||||
className="absolute left-0 right-0 -bottom-px h-0.5 rounded-full bg-krow-blue"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full bg-white/60 backdrop-blur-xl p-1 border border-white/50 shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{normalized.map((tab) => {
|
||||
const active = tab.value === value;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onChange?.(tab.value)}
|
||||
className={cn(
|
||||
'relative inline-flex items-center gap-1.5 rounded-full font-medium transition-colors duration-base focus-ring',
|
||||
size === 'sm' ? 'px-3 py-1 text-caption' : 'px-4 py-1.5 text-body-sm',
|
||||
active ? 'text-white' : 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={`tab-pill-${groupId}`}
|
||||
className="absolute inset-0 rounded-full bg-krow-blue"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 inline-flex items-center gap-1.5">
|
||||
{tab.icon && <tab.icon className="w-3.5 h-3.5" aria-hidden="true" />}
|
||||
{tab.label}
|
||||
{tab.count != null && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-1.5 text-[10px] font-bold tabular-nums',
|
||||
active ? 'bg-white/25 text-white' : 'bg-surface-sunken text-ink-3'
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/ds/Timeline.jsx
Normal file
99
src/components/ds/Timeline.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StatusBadge } from '@/components/ds/StatusBadge';
|
||||
|
||||
const TONE_DOT = {
|
||||
brand: 'bg-krow-blue ring-krow-blue/20',
|
||||
success: 'bg-success ring-success/20',
|
||||
warning: 'bg-warning ring-warning/20',
|
||||
destructive: 'bg-destructive ring-destructive/20',
|
||||
neutral: 'bg-ink-4 ring-ink-4/20',
|
||||
};
|
||||
|
||||
/**
|
||||
* Timeline — a vertical sequence of events: a candidate's journey, an audit
|
||||
* trail, a career history.
|
||||
*
|
||||
* Items:
|
||||
* { id, title, description, timestamp, icon, tone, status, meta, current }
|
||||
*
|
||||
* The connecting rail is drawn per item rather than as one absolute line, so a
|
||||
* timeline can be nested inside a scrolling card without the rail detaching.
|
||||
*/
|
||||
export function Timeline({ items = [], className, compact = false }) {
|
||||
return (
|
||||
<ol className={cn('relative', className)}>
|
||||
{items.map((item, i) => {
|
||||
const isLast = i === items.length - 1;
|
||||
const Icon = item.icon;
|
||||
const tone = item.tone || (item.current ? 'brand' : 'neutral');
|
||||
|
||||
return (
|
||||
<li key={item.id ?? i} className="relative flex gap-3.5 pb-5 last:pb-0">
|
||||
{/* Rail + node */}
|
||||
<div className="relative flex flex-col items-center shrink-0">
|
||||
{Icon ? (
|
||||
<span
|
||||
className={cn(
|
||||
'grid place-items-center rounded-full ring-4 shrink-0 z-10',
|
||||
compact ? 'w-6 h-6' : 'w-8 h-8',
|
||||
tone === 'neutral' ? 'bg-surface-sunken text-ink-3 ring-white' : 'text-white ring-white',
|
||||
tone !== 'neutral' && TONE_DOT[tone]
|
||||
)}
|
||||
>
|
||||
<Icon className={compact ? 'w-3 h-3' : 'w-4 h-4'} aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full ring-4 ring-white shrink-0 z-10 mt-1',
|
||||
compact ? 'w-2 h-2' : 'w-2.5 h-2.5',
|
||||
TONE_DOT[tone],
|
||||
item.current && 'animate-pulse-ring'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLast && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="w-px flex-1 bg-border mt-1.5 min-h-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn('flex-1 min-w-0', compact ? '-mt-0.5' : '-mt-1')}>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium text-ink-1',
|
||||
compact ? 'text-body-sm' : 'text-body'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
{item.description && (
|
||||
<p className="text-body-sm text-ink-3 mt-0.5 leading-relaxed">{item.description}</p>
|
||||
)}
|
||||
{item.meta && <p className="text-caption text-ink-4 mt-1">{item.meta}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{item.status && <StatusBadge status={item.status} size="sm" />}
|
||||
{item.timestamp && (
|
||||
<time className="text-caption text-ink-4 whitespace-nowrap">{item.timestamp}</time>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.children}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
112
src/components/ds/Toggle.jsx
Normal file
112
src/components/ds/Toggle.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
/**
|
||||
* Toggle — a labelled switch row for settings.
|
||||
*
|
||||
* Wraps the Switch primitive with the label/description layout it is almost
|
||||
* always used with, and makes the whole row a label so the hit target is the
|
||||
* full width rather than the 36px control.
|
||||
*/
|
||||
export function Toggle({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label,
|
||||
description,
|
||||
disabled,
|
||||
/** `end` puts the switch on the right (settings rows); `start` on the left. */
|
||||
align = 'end',
|
||||
className,
|
||||
id: providedId,
|
||||
}) {
|
||||
const reactId = React.useId();
|
||||
const id = providedId || reactId;
|
||||
const descriptionId = description ? `${id}-description` : undefined;
|
||||
|
||||
const control = (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
aria-describedby={descriptionId}
|
||||
/>
|
||||
);
|
||||
|
||||
const text = (
|
||||
<span className="min-w-0">
|
||||
<span className="block text-body-sm font-medium text-ink-2">{label}</span>
|
||||
{description && (
|
||||
<span id={descriptionId} className="block text-caption text-ink-3 mt-0.5">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 cursor-pointer select-none',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
align === 'end' && 'justify-between',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{align === 'start' ? (
|
||||
<>
|
||||
{control}
|
||||
{text}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{text}
|
||||
{control}
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SegmentedToggle — a small pill group for switching between 2–4 mutually
|
||||
* exclusive views. Used where Tabs would be too heavy (chart ranges, EN/ES).
|
||||
*/
|
||||
export function SegmentedToggle({ options = [], value, onChange, size = 'default', className, ariaLabel }) {
|
||||
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 rounded-full bg-surface-sunken p-0.5 border border-border',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{normalized.map((option) => {
|
||||
const active = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => onChange?.(option.value)}
|
||||
className={cn(
|
||||
'rounded-full font-medium transition-all duration-base ease-out focus-ring-inset',
|
||||
size === 'sm' ? 'px-2.5 py-1 text-caption' : 'px-3 py-1.5 text-body-sm',
|
||||
active
|
||||
? 'bg-krow-blue text-white shadow-sm'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/components/ds/index.js
Normal file
125
src/components/ds/index.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* KROW design system — the single import surface for building pages.
|
||||
*
|
||||
* import { PageHeader, KpiCard, DataTable, StatusBadge } from '@/components/ds';
|
||||
*
|
||||
* Two layers sit underneath:
|
||||
*
|
||||
* - `components/ui/*` — Radix-based primitives (the mechanics: focus traps,
|
||||
* portals, keyboard handling). Restyled to the KROW tokens, not re-wrapped.
|
||||
* - `components/ds/*` — the app's own composites, built from those primitives.
|
||||
*
|
||||
* Primitives that need no composition are re-exported here rather than
|
||||
* duplicated, so there is exactly one Tooltip, one Popover and one Checkbox in
|
||||
* the codebase and pages never need to know which layer a component came from.
|
||||
*/
|
||||
|
||||
/* ── Foundation ─────────────────────────────────────────────────────────── */
|
||||
export { Surface, surfaceVariants } from './Surface';
|
||||
export { PageHeader, HeroHeader } from './PageHeader';
|
||||
export { SectionHeader } from './SectionHeader';
|
||||
|
||||
/* ── Actions ────────────────────────────────────────────────────────────── */
|
||||
export { Button, buttonVariants } from '@/components/ui/button';
|
||||
export { IconButton } from './IconButton';
|
||||
|
||||
/* ── Forms & controls ───────────────────────────────────────────────────── */
|
||||
export { Field } from './Field';
|
||||
export { Input, inputVariants } from '@/components/ui/input';
|
||||
export { Textarea } from '@/components/ui/textarea';
|
||||
export { Label } from '@/components/ui/label';
|
||||
export { SearchInput } from './SearchInput';
|
||||
export { MultiSelect } from './MultiSelect';
|
||||
export { Toggle, SegmentedToggle } from './Toggle';
|
||||
export { Checkbox } from '@/components/ui/checkbox';
|
||||
export { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
export { Switch } from '@/components/ui/switch';
|
||||
export { Slider } from '@/components/ui/slider';
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
/* ── Identity & status ──────────────────────────────────────────────────── */
|
||||
export { Avatar, AvatarGroup, initialsFrom } from './Avatar';
|
||||
export { Badge, badgeVariants } from '@/components/ui/badge';
|
||||
export { StatusBadge, STATUS_MAP } from './StatusBadge';
|
||||
|
||||
/* ── Navigation ─────────────────────────────────────────────────────────── */
|
||||
export { Tabs } from './Tabs';
|
||||
export { Pagination, buildPageRange } from './Pagination';
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
|
||||
/* ── Overlays ───────────────────────────────────────────────────────────── */
|
||||
export { Modal, ConfirmModal } from './Modal';
|
||||
export { Drawer } from './Drawer';
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
export { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
|
||||
/* ── Feedback ───────────────────────────────────────────────────────────── */
|
||||
export { Alert } from './Alert';
|
||||
export { toast } from './toast';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { LoadingState, LoadingOverlay, InlineLoading, Spinner } from './LoadingState';
|
||||
export {
|
||||
Skeleton,
|
||||
SkeletonCard,
|
||||
SkeletonChart,
|
||||
SkeletonKpi,
|
||||
SkeletonList,
|
||||
SkeletonRow,
|
||||
SkeletonTable,
|
||||
SkeletonText,
|
||||
} from './Skeleton';
|
||||
|
||||
/* ── Data display ───────────────────────────────────────────────────────── */
|
||||
export { KpiCard, MetricCard } from './KpiCard';
|
||||
export { DataTable } from './DataTable';
|
||||
export { FilterBar } from './FilterBar';
|
||||
export { Timeline } from './Timeline';
|
||||
export { ActivityCard } from './ActivityCard';
|
||||
export {
|
||||
AXIS_PROPS,
|
||||
CHART_COLORS,
|
||||
CHART_TONES,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartTooltip,
|
||||
} from './ChartContainer';
|
||||
export {
|
||||
ProgressBar,
|
||||
ProgressRing,
|
||||
ProgressSteps,
|
||||
ScoreMeter,
|
||||
toneForScore,
|
||||
} from './Progress';
|
||||
|
||||
/* ── Layout helpers ─────────────────────────────────────────────────────── */
|
||||
export { Grid, PageSection, Stack } from './Layout';
|
||||
80
src/components/ds/toast.js
Normal file
80
src/components/ds/toast.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { toast as hotToast } from 'react-hot-toast';
|
||||
|
||||
/**
|
||||
* Toast — transient confirmation of a user action.
|
||||
*
|
||||
* The app already depends on react-hot-toast (13 call sites), so this wraps it
|
||||
* rather than introducing a second toast system. Wrapping buys three things:
|
||||
* one place to set styling so toasts match the design system, a named API
|
||||
* (`toast.success` / `toast.error` / `toast.promise`) instead of ad-hoc option
|
||||
* objects, and a single default duration.
|
||||
*
|
||||
* Rule of thumb: toast for "that worked", `Alert` for "here is a condition you
|
||||
* need to keep seeing", `ConfirmModal` for "are you sure".
|
||||
*/
|
||||
|
||||
const BASE_STYLE = {
|
||||
borderRadius: '12px',
|
||||
background: '#FFFFFF',
|
||||
color: 'hsl(215 25% 27%)',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
padding: '10px 14px',
|
||||
boxShadow: '0 12px 28px -6px rgb(16 24 40 / 0.12), 0 4px 10px -4px rgb(16 24 40 / 0.06)',
|
||||
border: '1px solid hsl(150 8% 87%)',
|
||||
maxWidth: '420px',
|
||||
};
|
||||
|
||||
const DURATIONS = { success: 2600, error: 4200, info: 3000, loading: Infinity };
|
||||
|
||||
const withDefaults = (options = {}) => ({
|
||||
position: 'bottom-center',
|
||||
...options,
|
||||
style: { ...BASE_STYLE, ...(options.style || {}) },
|
||||
});
|
||||
|
||||
export const toast = {
|
||||
success(message, options) {
|
||||
return hotToast.success(message, {
|
||||
duration: DURATIONS.success,
|
||||
iconTheme: { primary: 'hsl(142 71% 36%)', secondary: '#FFFFFF' },
|
||||
...withDefaults(options),
|
||||
});
|
||||
},
|
||||
|
||||
error(message, options) {
|
||||
return hotToast.error(message, {
|
||||
duration: DURATIONS.error,
|
||||
iconTheme: { primary: 'hsl(0 84% 60%)', secondary: '#FFFFFF' },
|
||||
...withDefaults(options),
|
||||
});
|
||||
},
|
||||
|
||||
info(message, options) {
|
||||
return hotToast(message, { duration: DURATIONS.info, ...withDefaults(options) });
|
||||
},
|
||||
|
||||
loading(message, options) {
|
||||
return hotToast.loading(message, { duration: DURATIONS.loading, ...withDefaults(options) });
|
||||
},
|
||||
|
||||
/**
|
||||
* Ties a toast to an async action: loading while it runs, then success or
|
||||
* error. Preferred over a manual loading/dismiss pair, which leaks a spinner
|
||||
* whenever the promise rejects.
|
||||
*/
|
||||
promise(promise, messages, options) {
|
||||
return hotToast.promise(
|
||||
promise,
|
||||
{
|
||||
loading: messages?.loading ?? 'Working…',
|
||||
success: messages?.success ?? 'Done',
|
||||
error: messages?.error ?? 'Something went wrong',
|
||||
},
|
||||
withDefaults(options)
|
||||
);
|
||||
},
|
||||
|
||||
dismiss: hotToast.dismiss,
|
||||
remove: hotToast.remove,
|
||||
};
|
||||
Reference in New Issue
Block a user