@medalsocial/meda 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/components/ui/checkbox.d.ts +8 -0
  2. package/dist/components/ui/checkbox.js +9 -0
  3. package/dist/components/ui/collapsible.d.ts +5 -0
  4. package/dist/components/ui/collapsible.js +13 -0
  5. package/dist/kanban/index.d.ts +5 -0
  6. package/dist/kanban/index.js +4 -0
  7. package/dist/kanban/kanban-board.d.ts +8 -0
  8. package/dist/kanban/kanban-board.js +197 -0
  9. package/dist/kanban/kanban-card-wrapper.d.ts +16 -0
  10. package/dist/kanban/kanban-card-wrapper.js +20 -0
  11. package/dist/kanban/kanban-collision.d.ts +16 -0
  12. package/dist/kanban/kanban-collision.js +18 -0
  13. package/dist/kanban/kanban-column.d.ts +19 -0
  14. package/dist/kanban/kanban-column.js +19 -0
  15. package/dist/kanban/kanban-drop-handler.d.ts +16 -0
  16. package/dist/kanban/kanban-drop-handler.js +84 -0
  17. package/dist/kanban/types.d.ts +47 -0
  18. package/dist/kanban/types.js +7 -0
  19. package/dist/list/index.d.ts +2 -0
  20. package/dist/list/index.js +1 -0
  21. package/dist/list/list-row.d.ts +52 -0
  22. package/dist/list/list-row.js +37 -0
  23. package/dist/shell/app-shell-workspace.js +11 -6
  24. package/dist/shell/command-palette.js +2 -1
  25. package/dist/shell/context-rail.d.ts +1 -1
  26. package/dist/shell/context-rail.js +5 -4
  27. package/dist/shell/drag-mode-banner.d.ts +31 -0
  28. package/dist/shell/drag-mode-banner.js +19 -0
  29. package/dist/shell/index.d.ts +8 -0
  30. package/dist/shell/index.js +5 -0
  31. package/dist/shell/internal/mobile-drawers.d.ts +8 -2
  32. package/dist/shell/internal/mobile-drawers.js +40 -12
  33. package/dist/shell/panel-views-provider.d.ts +15 -0
  34. package/dist/shell/panel-views-provider.js +56 -0
  35. package/dist/shell/rail-drop-slot.d.ts +41 -0
  36. package/dist/shell/rail-drop-slot.js +49 -0
  37. package/dist/shell/rail-drop-zones.d.ts +28 -0
  38. package/dist/shell/rail-drop-zones.js +17 -0
  39. package/dist/shell/right-panel.js +21 -8
  40. package/dist/shell/shell-provider.d.ts +14 -1
  41. package/dist/shell/shell-provider.js +64 -2
  42. package/dist/shell/types.d.ts +5 -1
  43. package/dist/timeline/index.d.ts +3 -0
  44. package/dist/timeline/index.js +2 -0
  45. package/dist/timeline/lane-timeline-types.d.ts +61 -0
  46. package/dist/timeline/lane-timeline-types.js +6 -0
  47. package/dist/timeline/lane-timeline.d.ts +2 -0
  48. package/dist/timeline/lane-timeline.js +85 -0
  49. package/dist/timeline/lane.d.ts +10 -0
  50. package/dist/timeline/lane.js +39 -0
  51. package/dist/timeline/public.d.ts +1 -0
  52. package/dist/timeline/time-axis.d.ts +18 -0
  53. package/dist/timeline/time-axis.js +22 -0
  54. package/package.json +24 -1
@@ -18,6 +18,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
18
  import { Maximize2, Minimize2, X } from 'lucide-react';
19
19
  import { useEffect, useRef, useState } from 'react';
20
20
  import { cn } from '../lib/utils.js';
21
+ import { useResolvedPanelViews } from './panel-views-provider.js';
21
22
  import { useMedaShell } from './shell-provider.js';
22
23
  import { useShellViewport } from './use-shell-viewport.js';
23
24
  // ---------------------------------------------------------------------------
@@ -25,6 +26,7 @@ import { useShellViewport } from './use-shell-viewport.js';
25
26
  // ---------------------------------------------------------------------------
26
27
  const MIN_WIDTH = 300;
27
28
  const MAX_WIDTH = 520;
29
+ const EMPTY_PANEL_VIEWS = [];
28
30
  function ResizeHandle({ currentWidth, onResize, onCommit }) {
29
31
  const startWidthRef = useRef(currentWidth);
30
32
  const startXRef = useRef(0);
@@ -61,17 +63,28 @@ function ResizeHandle({ currentWidth, onResize, onCommit }) {
61
63
  // ---------------------------------------------------------------------------
62
64
  // RightPanel
63
65
  // ---------------------------------------------------------------------------
64
- export function RightPanel({ panelViews = [], defaultView, modes = ['panel', 'expanded', 'fullscreen'], className, }) {
66
+ export function RightPanel({ panelViews = EMPTY_PANEL_VIEWS, defaultView, modes = ['panel', 'expanded', 'fullscreen'], className, }) {
65
67
  const band = useShellViewport();
66
68
  const ctx = useMedaShell();
67
69
  const { mode, activeView, width, setMode, setActiveView, setWidth } = ctx.panel;
68
- // Hydrate defaultView on mount if no active view set
69
- // biome-ignore lint/correctness/useExhaustiveDependencies: intentional one-time mount effect
70
+ const { panelViews: resolvedPanelViews, defaultView: resolvedDefaultView } = useResolvedPanelViews(panelViews, defaultView);
71
+ const hydratedDefaultRef = useRef(null);
72
+ // Hydrate a default when static or route-registered views become available.
70
73
  useEffect(() => {
71
- if (!activeView && defaultView) {
72
- setActiveView(defaultView);
74
+ const defaultExists = resolvedDefaultView != null &&
75
+ resolvedPanelViews.some((view) => view.id === resolvedDefaultView);
76
+ if (!defaultExists)
77
+ return;
78
+ const source = resolvedDefaultView !== defaultView ? 'registered' : 'static';
79
+ const canReplaceStaticDefault = activeView === hydratedDefaultRef.current?.id &&
80
+ hydratedDefaultRef.current.source === 'static' &&
81
+ source === 'registered' &&
82
+ activeView !== resolvedDefaultView;
83
+ if (!activeView || canReplaceStaticDefault) {
84
+ hydratedDefaultRef.current = { id: resolvedDefaultView, source };
85
+ setActiveView(resolvedDefaultView);
73
86
  }
74
- }, []);
87
+ }, [activeView, defaultView, resolvedDefaultView, resolvedPanelViews, setActiveView]);
75
88
  // Local display width tracks pointer-move updates live;
76
89
  // setWidth (persist) is called on pointerUp.
77
90
  const [displayWidth, setDisplayWidth] = useState(null);
@@ -109,14 +122,14 @@ export function RightPanel({ panelViews = [], defaultView, modes = ['panel', 'ex
109
122
  })();
110
123
  // Z-index escalation: fullscreen covers header + rails
111
124
  const zIndexClass = mode === 'fullscreen' ? 'z-[var(--z-shell-fullscreen)]' : 'z-[var(--z-shell-panel)]';
112
- const activePanelView = panelViews.find((v) => v.id === activeView);
125
+ const activePanelView = resolvedPanelViews.find((v) => v.id === activeView);
113
126
  const cycleAriaLabel = mode === 'panel' ? 'Expand panel' : mode === 'expanded' ? 'Maximize panel' : 'Restore panel';
114
127
  const handleResize = (w) => setDisplayWidth(w);
115
128
  const handleCommit = (w) => {
116
129
  setDisplayWidth(null);
117
130
  setWidth(w);
118
131
  };
119
- return (_jsx("aside", { "data-meda-panel-mode": mode, "aria-hidden": mode === 'closed' ? 'true' : undefined, className: cn('relative h-full shrink-0 overflow-hidden border-l border-shell-border bg-shell-panel', 'transition-[width] ease-[var(--motion-ease)] duration-[var(--motion-panel)]', mode === 'fullscreen' && 'fixed inset-0 h-screen w-screen border-none', zIndexClass, className), style: widthStyle, children: mode !== 'closed' && (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-shell-border px-3 py-2", children: [_jsx("div", { className: "flex items-center gap-1", children: panelViews.map((view) => {
132
+ return (_jsx("aside", { "data-meda-panel-mode": mode, "aria-hidden": mode === 'closed' ? 'true' : undefined, className: cn('relative h-full shrink-0 overflow-hidden border-l border-shell-border bg-shell-panel', 'transition-[width] ease-[var(--motion-ease)] duration-[var(--motion-panel)]', mode === 'fullscreen' && 'fixed inset-0 h-screen w-screen border-none', zIndexClass, className), style: widthStyle, children: mode !== 'closed' && (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-shell-border px-3 py-2", children: [_jsx("div", { className: "flex items-center gap-1", children: resolvedPanelViews.map((view) => {
120
133
  const isActive = view.id === activeView;
121
134
  const Icon = view.icon;
122
135
  return (_jsxs("button", { type: "button", "aria-current": isActive ? 'true' : undefined, onClick: () => setActiveView(view.id), className: cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors', isActive
@@ -1,7 +1,12 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type ShellStorageAdapter } from './layout-state.js';
3
- import type { AppDefinition, MobileBottomNavItem, PanelMode, ThemeAdapter, WorkspaceDefinition } from './types.js';
3
+ import type { AppDefinition, MobileBottomNavItem, PanelMode, PanelView, ThemeAdapter, WorkspaceDefinition } from './types.js';
4
4
  export type MobileDrawerKind = 'menu-drawer' | 'module-drawer' | 'panels-drawer' | 'ai-drawer' | (string & {}) | null;
5
+ export interface PanelViewRegistration {
6
+ id: string;
7
+ views: PanelView[];
8
+ defaultView?: string;
9
+ }
5
10
  interface MedaShellContextValue {
6
11
  workspace: WorkspaceDefinition;
7
12
  workspaces: WorkspaceDefinition[];
@@ -15,6 +20,9 @@ interface MedaShellContextValue {
15
20
  setMode: (m: PanelMode) => void;
16
21
  setActiveView: (v: string | null) => void;
17
22
  setWidth: (w: number) => void;
23
+ open: () => void;
24
+ close: () => void;
25
+ toggle: () => void;
18
26
  /** Opens panel + switches to viewId in one call.
19
27
  * Sugar for app keyboard shortcuts (e.g. Cmd+J → panel.focus('ai')).
20
28
  * If already open in 'panel', 'expanded', or 'fullscreen', the existing
@@ -26,6 +34,7 @@ interface MedaShellContextValue {
26
34
  collapsed: boolean;
27
35
  setWidth: (w: number) => void;
28
36
  setCollapsed: (c: boolean) => void;
37
+ toggle: () => void;
29
38
  };
30
39
  mobileBottomNav: MobileBottomNavItem[];
31
40
  mobileDrawer: {
@@ -36,6 +45,10 @@ interface MedaShellContextValue {
36
45
  open: boolean;
37
46
  setOpen: (open: boolean) => void;
38
47
  };
48
+ panelViews: {
49
+ registrations: PanelViewRegistration[];
50
+ register: (id: string, views: PanelView[], defaultView?: string) => () => void;
51
+ };
39
52
  commandPaletteHotkey: string;
40
53
  /** Selection bridge between main workspace and right panel views (spec §17). */
41
54
  selection: unknown | null;
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { LayoutGrid, Menu, PanelTop, Sparkles } from 'lucide-react';
4
- import { createContext, lazy, Suspense, useContext, useMemo, useState, } from 'react';
4
+ import { createContext, lazy, Suspense, useCallback, useContext, useMemo, useState, } from 'react';
5
5
  import { createLocalStorageAdapter, useShellLayoutState, } from './layout-state.js';
6
6
  import { DefaultThemeProvider, ThemeCtx } from './theme.js';
7
+ import { useShellViewport } from './use-shell-viewport.js';
7
8
  // ---------------------------------------------------------------------------
8
9
  // Theme adapter wiring
9
10
  // ---------------------------------------------------------------------------
@@ -78,11 +79,35 @@ export function MedaShellProvider(props) {
78
79
  open: commandPaletteOpen,
79
80
  setOpen: setCommandPaletteOpen,
80
81
  }), [commandPaletteOpen]);
82
+ const [panelViewRegistrations, setPanelViewRegistrations] = useState([]);
83
+ const registerPanelViews = useCallback((id, views, defaultView) => {
84
+ setPanelViewRegistrations((prev) => {
85
+ const existing = prev.find((registration) => registration.id === id);
86
+ if (existing?.views === views && existing.defaultView === defaultView)
87
+ return prev;
88
+ const nextRegistration = defaultView === undefined ? { id, views } : { id, views, defaultView };
89
+ if (existing == null)
90
+ return [...prev, nextRegistration];
91
+ return prev.map((registration) => (registration.id === id ? nextRegistration : registration));
92
+ });
93
+ return () => {
94
+ setPanelViewRegistrations((prev) => prev.some((registration) => registration.id === id &&
95
+ registration.views === views &&
96
+ registration.defaultView === defaultView)
97
+ ? prev.filter((registration) => registration.id !== id)
98
+ : prev);
99
+ };
100
+ }, []);
101
+ const panelViews = useMemo(() => ({
102
+ registrations: panelViewRegistrations,
103
+ register: registerPanelViews,
104
+ }), [panelViewRegistrations, registerPanelViews]);
81
105
  const [layoutState, setLayoutState] = useShellLayoutState({
82
106
  workspaceId: props.workspace.id,
83
107
  appId: activeAppId,
84
108
  storage,
85
109
  });
110
+ const isMobile = useShellViewport() === 'mobile';
86
111
  const panel = useMemo(() => ({
87
112
  mode: layoutState.rightPanel.mode,
88
113
  activeView: layoutState.rightPanel.activeView,
@@ -99,6 +124,37 @@ export function MedaShellProvider(props) {
99
124
  ...prev,
100
125
  rightPanel: { ...prev.rightPanel, width },
101
126
  })),
127
+ open: () => {
128
+ if (isMobile)
129
+ setMobileDrawerOpen('panels-drawer');
130
+ setLayoutState((prev) => ({
131
+ ...prev,
132
+ rightPanel: {
133
+ ...prev.rightPanel,
134
+ mode: prev.rightPanel.mode === 'closed' ? 'panel' : prev.rightPanel.mode,
135
+ },
136
+ }));
137
+ },
138
+ close: () => {
139
+ if (isMobile)
140
+ setMobileDrawerOpen((open) => (open === 'panels-drawer' ? null : open));
141
+ setLayoutState((prev) => ({
142
+ ...prev,
143
+ rightPanel: { ...prev.rightPanel, mode: 'closed' },
144
+ }));
145
+ },
146
+ toggle: () => {
147
+ if (isMobile) {
148
+ setMobileDrawerOpen((open) => (open === 'panels-drawer' ? null : 'panels-drawer'));
149
+ }
150
+ setLayoutState((prev) => ({
151
+ ...prev,
152
+ rightPanel: {
153
+ ...prev.rightPanel,
154
+ mode: prev.rightPanel.mode === 'closed' ? 'panel' : 'closed',
155
+ },
156
+ }));
157
+ },
102
158
  // focus(viewId) — opens panel + switches to view in one call.
103
159
  // Only flips closed → panel; preserves expanded / fullscreen modes.
104
160
  focus: (viewId) => setLayoutState((prev) => {
@@ -108,7 +164,7 @@ export function MedaShellProvider(props) {
108
164
  rightPanel: { ...prev.rightPanel, mode: nextMode, activeView: viewId },
109
165
  };
110
166
  }),
111
- }), [layoutState, setLayoutState]);
167
+ }), [isMobile, layoutState, setLayoutState]);
112
168
  const contextRail = useMemo(() => ({
113
169
  width: layoutState.contextRail.width,
114
170
  collapsed: layoutState.contextRail.collapsed,
@@ -120,6 +176,10 @@ export function MedaShellProvider(props) {
120
176
  ...prev,
121
177
  contextRail: { ...prev.contextRail, collapsed },
122
178
  })),
179
+ toggle: () => setLayoutState((prev) => ({
180
+ ...prev,
181
+ contextRail: { ...prev.contextRail, collapsed: !prev.contextRail.collapsed },
182
+ })),
123
183
  }), [layoutState, setLayoutState]);
124
184
  const value = useMemo(() => ({
125
185
  workspace: props.workspace,
@@ -132,6 +192,7 @@ export function MedaShellProvider(props) {
132
192
  mobileBottomNav: props.mobileBottomNav ?? defaultMobileBottomNav,
133
193
  mobileDrawer,
134
194
  commandPalette,
195
+ panelViews,
135
196
  commandPaletteHotkey: props.commandPaletteHotkey ?? 'mod+k',
136
197
  selection,
137
198
  setSelection,
@@ -145,6 +206,7 @@ export function MedaShellProvider(props) {
145
206
  props.mobileBottomNav,
146
207
  mobileDrawer,
147
208
  commandPalette,
209
+ panelViews,
148
210
  props.commandPaletteHotkey,
149
211
  selection,
150
212
  ]);
@@ -14,7 +14,8 @@ export interface ContextModule {
14
14
  id: string;
15
15
  label: string;
16
16
  description?: string;
17
- items: ContextItem[];
17
+ items?: ContextItem[];
18
+ render?: (ctx: ShellRenderContext) => ReactNode;
18
19
  }
19
20
  export interface ContextItem {
20
21
  id: string;
@@ -55,6 +56,8 @@ export interface CommandDefinition {
55
56
  icon?: LucideIcon;
56
57
  group: string;
57
58
  shortcut?: string;
59
+ /** Display-only alias for `shortcut`; `shortcut` wins when both are supplied. */
60
+ hotkey?: string;
58
61
  run: () => void | Promise<void>;
59
62
  }
60
63
  export interface ThemeAdapter {
@@ -76,6 +79,7 @@ export interface AppShellIconRailConfig {
76
79
  utilityItems?: import('./icon-rail.js').IconRailItem[];
77
80
  footer?: ReactNode;
78
81
  activeId?: string;
82
+ renderLink?: import('./icon-rail.js').IconRailProps['renderLink'];
79
83
  }
80
84
  /** ContextRail configuration for `<AppShell variant="workspace">`. */
81
85
  export interface AppShellContextRailConfig {
@@ -2,6 +2,9 @@ export type { DateSwitcherProps } from './date-switcher.js';
2
2
  export { DateSwitcher } from './date-switcher.js';
3
3
  export type { EventCardProps, EventCardSize } from './event-card.js';
4
4
  export { EventCard } from './event-card.js';
5
+ export { LaneTimeline } from './lane-timeline.js';
6
+ export type { Lane, LaneBar, LaneLegendItem, LaneTimelineLabels, LaneTimelineProps, LaneTimelineRange, } from './lane-timeline-types.js';
7
+ export { defaultLaneTimelineLabels } from './lane-timeline-types.js';
5
8
  export type { LiveIndicatorProps } from './live-indicator.js';
6
9
  export { LiveIndicator } from './live-indicator.js';
7
10
  export type { ScrubBarProps } from './scrub-bar.js';
@@ -1,5 +1,7 @@
1
1
  export { DateSwitcher } from './date-switcher.js';
2
2
  export { EventCard } from './event-card.js';
3
+ export { LaneTimeline } from './lane-timeline.js';
4
+ export { defaultLaneTimelineLabels } from './lane-timeline-types.js';
3
5
  export { LiveIndicator } from './live-indicator.js';
4
6
  export { ScrubBar } from './scrub-bar.js';
5
7
  export { TimelineRail } from './timeline-rail.js';
@@ -0,0 +1,61 @@
1
+ import type { ReactNode } from 'react';
2
+ export type LaneTimelineRange = '1h' | '6h' | '24h' | '7d';
3
+ export interface LaneBar {
4
+ id: string;
5
+ start: Date;
6
+ end: Date;
7
+ label: string;
8
+ /** Tailwind class for the bar fill, e.g. 'bg-success-500/80'. */
9
+ fillClass: string;
10
+ /** Optional left-edge accent class, e.g. 'border-l-4 border-l-success-700'. */
11
+ accentClass?: string;
12
+ }
13
+ export interface Lane {
14
+ id: string;
15
+ label: string;
16
+ /** Optional secondary line under label (e.g. "3 sessions" or "offline"). */
17
+ sublabel?: string;
18
+ /** Tailwind class for the leading status dot (e.g. 'bg-success-500'). */
19
+ statusDotClass?: string;
20
+ bars: LaneBar[];
21
+ /** Render lane label muted; bars still render. */
22
+ muted?: boolean;
23
+ }
24
+ export interface LaneLegendItem {
25
+ label: string;
26
+ swatchClass: string;
27
+ }
28
+ export interface LaneTimelineLabels {
29
+ previousDate: string;
30
+ nextDate: string;
31
+ now: string;
32
+ /** "{n} active" — `{n}` replaced at render. */
33
+ activeCount: string;
34
+ }
35
+ export declare const defaultLaneTimelineLabels: LaneTimelineLabels;
36
+ export interface TimeTick {
37
+ /** Display label, e.g. "09:00". */
38
+ label: string;
39
+ /** Position as percentage 0–100 along the time axis. */
40
+ position: number;
41
+ }
42
+ export interface LaneTimelineProps {
43
+ lanes: Lane[];
44
+ /** Reference time for now-line + window end. Defaults to `new Date()`. */
45
+ now?: Date;
46
+ defaultRange?: LaneTimelineRange;
47
+ range?: LaneTimelineRange;
48
+ onRangeChange?: (range: LaneTimelineRange) => void;
49
+ selectedDate?: Date;
50
+ onDateChange?: (date: Date) => void;
51
+ title?: ReactNode;
52
+ /** Render-prop slot for a "Group: …" chip in the header. */
53
+ groupChip?: ReactNode;
54
+ /** Number of currently-active bars; powers the "● {n} active" indicator. */
55
+ activeCount?: number;
56
+ legend?: LaneLegendItem[];
57
+ selectedBarId?: string;
58
+ onSelectBar?: (bar: LaneBar, lane: Lane) => void;
59
+ labels?: Partial<LaneTimelineLabels>;
60
+ className?: string;
61
+ }
@@ -0,0 +1,6 @@
1
+ export const defaultLaneTimelineLabels = {
2
+ previousDate: 'Previous day',
3
+ nextDate: 'Next day',
4
+ now: 'now',
5
+ activeCount: '{n} active',
6
+ };
@@ -0,0 +1,2 @@
1
+ import { type LaneTimelineProps } from './lane-timeline-types.js';
2
+ export declare function LaneTimeline({ lanes, now, defaultRange, range: controlledRange, onRangeChange, selectedDate: controlledDate, onDateChange, title, groupChip, activeCount, legend, selectedBarId, onSelectBar, labels, className, }: LaneTimelineProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useMemo, useState } from 'react';
4
+ import { cn } from '../lib/utils.js';
5
+ import { LaneRow } from './lane.js';
6
+ import { defaultLaneTimelineLabels, } from './lane-timeline-types.js';
7
+ import { TimeAxis } from './time-axis.js';
8
+ const LABEL_GUTTER_PX = 140;
9
+ const RANGE_MS = {
10
+ '1h': 60 * 60_000,
11
+ '6h': 6 * 60 * 60_000,
12
+ '24h': 24 * 60 * 60_000,
13
+ '7d': 7 * 24 * 60 * 60_000,
14
+ };
15
+ export function LaneTimeline({ lanes, now, defaultRange = '6h', range: controlledRange, onRangeChange, selectedDate: controlledDate, onDateChange, title, groupChip, activeCount, legend, selectedBarId, onSelectBar, labels, className, }) {
16
+ const resolvedLabels = { ...defaultLaneTimelineLabels, ...(labels ?? {}) };
17
+ const [internalRange, setInternalRange] = useState(defaultRange);
18
+ const [internalDate, setInternalDate] = useState(() => now ?? new Date());
19
+ const range = controlledRange ?? internalRange;
20
+ const selectedDate = controlledDate ?? internalDate;
21
+ const referenceNow = now ?? new Date();
22
+ const { windowStart, windowEnd } = useMemo(() => {
23
+ const rangeMs = RANGE_MS[range];
24
+ const futurePadMs = Math.max(rangeMs / 6, 5 * 60_000);
25
+ const isToday = selectedDate.toDateString() === referenceNow.toDateString();
26
+ const end = isToday ? new Date(referenceNow.getTime() + futurePadMs) : endOfDay(selectedDate);
27
+ // futurePadMs is only relevant for the "today" path (gives the now-line breathing room).
28
+ // Historical days should render exactly rangeMs of window — no asymmetric pad.
29
+ const start = new Date(end.getTime() - rangeMs - (isToday ? futurePadMs : 0));
30
+ return { windowStart: start, windowEnd: end };
31
+ }, [range, selectedDate, referenceNow]);
32
+ const ticks = useMemo(() => buildTicks(windowStart, windowEnd, range), [windowStart, windowEnd, range]);
33
+ const handleRange = (r) => {
34
+ if (controlledRange === undefined)
35
+ setInternalRange(r);
36
+ onRangeChange?.(r);
37
+ };
38
+ const handleDate = (d) => {
39
+ if (controlledDate === undefined)
40
+ setInternalDate(d);
41
+ onDateChange?.(d);
42
+ };
43
+ const showNowLine = referenceNow.getTime() >= windowStart.getTime() &&
44
+ referenceNow.getTime() <= windowEnd.getTime();
45
+ const nowPositionPct = showNowLine
46
+ ? ((referenceNow.getTime() - windowStart.getTime()) /
47
+ (windowEnd.getTime() - windowStart.getTime())) *
48
+ 100
49
+ : 0;
50
+ return (_jsxs("section", { className: cn('flex flex-col rounded-lg border border-border bg-card p-4', className), children: [_jsx(TimeAxis, { range: range, onRangeChange: handleRange, selectedDate: selectedDate, onDateChange: handleDate, now: referenceNow, title: title, groupChip: groupChip, activeCount: activeCount, labels: resolvedLabels, ticks: ticks, labelGutterPx: LABEL_GUTTER_PX }), _jsxs("div", { className: "relative", children: [lanes.map((lane) => (_jsx(LaneRow, { lane: lane, windowStart: windowStart, windowEnd: windowEnd, labelGutterPx: LABEL_GUTTER_PX, selectedBarId: selectedBarId, onSelectBar: onSelectBar }, lane.id))), showNowLine && (_jsx("div", { "aria-hidden": "true", className: "pointer-events-none absolute top-0 bottom-0 w-px bg-primary", style: {
51
+ left: `calc(${LABEL_GUTTER_PX}px + (100% - ${LABEL_GUTTER_PX}px) * ${nowPositionPct / 100})`,
52
+ }, children: _jsx("div", { className: "-translate-x-1/2 -top-1 absolute size-2 rounded-full bg-primary" }) }))] }), legend && legend.length > 0 && (_jsxs("div", { className: "mt-3 flex items-center gap-4 border-border/40 border-t pt-3 text-muted-foreground text-xs", children: [_jsx("span", { className: "uppercase tracking-wider", children: "Legend" }), legend.map((item) => (_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: cn('inline-block size-2 rounded-sm', item.swatchClass) }), item.label] }, item.label))), showNowLine && (_jsxs("span", { className: "ml-auto flex items-center gap-1.5", children: [_jsx("span", { className: "inline-block size-2 rounded-full bg-primary" }), resolvedLabels.now, " \u00B7 ", formatTime(referenceNow)] }))] }))] }));
53
+ }
54
+ function endOfDay(d) {
55
+ const e = new Date(d);
56
+ e.setHours(23, 59, 59, 999);
57
+ return e;
58
+ }
59
+ function formatTime(d) {
60
+ return new Intl.DateTimeFormat(undefined, {
61
+ hour: '2-digit',
62
+ minute: '2-digit',
63
+ hour12: false,
64
+ }).format(d);
65
+ }
66
+ function buildTicks(start, end, range) {
67
+ const span = end.getTime() - start.getTime();
68
+ const tickCount = range === '7d' ? 7 : range === '24h' ? 8 : 7;
69
+ const out = [];
70
+ for (let i = 0; i <= tickCount; i++) {
71
+ const t = new Date(start.getTime() + (span * i) / tickCount);
72
+ out.push({ position: (i / tickCount) * 100, label: formatTickLabel(t, range) });
73
+ }
74
+ return out;
75
+ }
76
+ function formatTickLabel(d, range) {
77
+ if (range === '7d') {
78
+ return new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(d);
79
+ }
80
+ return new Intl.DateTimeFormat(undefined, {
81
+ hour: '2-digit',
82
+ minute: '2-digit',
83
+ hour12: false,
84
+ }).format(d);
85
+ }
@@ -0,0 +1,10 @@
1
+ import type { Lane, LaneBar } from './lane-timeline-types.js';
2
+ export interface LaneRowProps {
3
+ lane: Lane;
4
+ windowStart: Date;
5
+ windowEnd: Date;
6
+ labelGutterPx: number;
7
+ selectedBarId?: string;
8
+ onSelectBar?: (bar: LaneBar, lane: Lane) => void;
9
+ }
10
+ export declare function LaneRow({ lane, windowStart, windowEnd, labelGutterPx, selectedBarId, onSelectBar, }: LaneRowProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn } from '../lib/utils.js';
4
+ export function LaneRow({ lane, windowStart, windowEnd, labelGutterPx, selectedBarId, onSelectBar, }) {
5
+ return (_jsxs("div", { className: "grid items-center gap-0 border-border/40 border-b py-3", style: { gridTemplateColumns: `${labelGutterPx}px 1fr` }, children: [_jsxs("div", { className: "flex flex-col gap-0.5 pr-3", children: [_jsxs("div", { className: cn('flex items-center gap-2 text-sm', lane.muted && 'text-muted-foreground'), children: [lane.statusDotClass && (_jsx("span", { className: cn('inline-block size-2 rounded-full', lane.statusDotClass, lane.muted && 'opacity-40') })), _jsx("span", { className: "font-medium", children: lane.label })] }), lane.sublabel && _jsx("div", { className: "text-muted-foreground text-xs", children: lane.sublabel })] }), _jsx("div", { className: "relative h-7", children: lane.bars.map((bar) => {
6
+ const placement = placeBar(bar, windowStart, windowEnd);
7
+ if (!placement)
8
+ return null;
9
+ const selected = selectedBarId === bar.id;
10
+ const isSelectable = !!onSelectBar;
11
+ const barClassName = cn('absolute top-0 flex h-full items-center overflow-hidden rounded-md px-2 text-left text-xs text-white', bar.fillClass, bar.accentClass, isSelectable && [
12
+ 'data-[selected]:ring-2 data-[selected]:ring-primary data-[selected]:ring-offset-1 data-[selected]:ring-offset-background',
13
+ 'hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
14
+ ]);
15
+ const barStyle = { left: `${placement.left}%`, width: `${placement.width}%` };
16
+ const ariaLabel = `${lane.label} — ${bar.label}`;
17
+ if (!isSelectable) {
18
+ return (_jsx("div", { role: "img", "aria-label": ariaLabel, className: barClassName, style: barStyle, children: _jsx("span", { "aria-hidden": "true", className: "truncate", children: bar.label }) }, bar.id));
19
+ }
20
+ return (_jsx("button", { type: "button", onClick: () => onSelectBar(bar, lane), "data-selected": selected || undefined, className: barClassName, style: barStyle, "aria-label": ariaLabel, children: _jsx("span", { "aria-hidden": "true", className: "truncate", children: bar.label }) }, bar.id));
21
+ }) })] }));
22
+ }
23
+ function placeBar(bar, windowStart, windowEnd) {
24
+ const ws = windowStart.getTime();
25
+ const we = windowEnd.getTime();
26
+ const span = we - ws;
27
+ if (span <= 0)
28
+ return null;
29
+ const bs = bar.start.getTime();
30
+ const be = bar.end.getTime();
31
+ // Bar entirely outside the window (or exactly touching boundary) — skip.
32
+ if (be <= ws || bs >= we)
33
+ return null;
34
+ const clampedStart = Math.max(bs, ws);
35
+ const clampedEnd = Math.min(be, we);
36
+ const left = ((clampedStart - ws) / span) * 100;
37
+ const width = Math.max(((clampedEnd - clampedStart) / span) * 100, 0.5);
38
+ return { left, width };
39
+ }
@@ -1 +1,2 @@
1
+ export type { Lane, LaneBar, LaneLegendItem, LaneTimelineRange, } from './lane-timeline-types.js';
1
2
  export type { ScrubMark, TimelineEvent, TimelineEventKind } from './types.js';
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { LaneTimelineLabels, LaneTimelineRange, TimeTick } from './lane-timeline-types.js';
3
+ export interface TimeAxisProps {
4
+ range: LaneTimelineRange;
5
+ onRangeChange: (range: LaneTimelineRange) => void;
6
+ selectedDate: Date;
7
+ onDateChange: (date: Date) => void;
8
+ /** Reference time used to determine whether `selectedDate` is "today" in the header label. */
9
+ now: Date;
10
+ title?: ReactNode;
11
+ groupChip?: ReactNode;
12
+ activeCount?: number;
13
+ labels: LaneTimelineLabels;
14
+ ticks: TimeTick[];
15
+ /** Width of the lane-label gutter on the left, in px (matches Lane component). */
16
+ labelGutterPx: number;
17
+ }
18
+ export declare function TimeAxis({ range, onRangeChange, selectedDate, onDateChange, now, title, groupChip, activeCount, labels, ticks, labelGutterPx, }: TimeAxisProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
4
+ import { cn } from '../lib/utils.js';
5
+ const RANGES = ['1h', '6h', '24h', '7d'];
6
+ export function TimeAxis({ range, onRangeChange, selectedDate, onDateChange, now, title, groupChip, activeCount, labels, ticks, labelGutterPx, }) {
7
+ const stepDate = (delta) => {
8
+ const next = new Date(selectedDate);
9
+ next.setDate(next.getDate() + delta);
10
+ onDateChange(next);
11
+ };
12
+ return (_jsxs("div", { className: "flex flex-col gap-3 border-border border-b pb-3", children: [_jsxs("div", { className: "flex items-center gap-3", children: [title && _jsx("div", { className: "font-medium text-sm", children: title }), _jsx("div", { className: "flex items-center gap-1 rounded-md bg-muted p-0.5", children: RANGES.map((r) => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => onRangeChange(r), "data-active": r === range || undefined, className: cn('rounded px-2 py-0.5 text-xs', 'data-[active]:bg-background data-[active]:text-foreground', r !== range && 'text-muted-foreground hover:text-foreground'), children: r }, r))) }), groupChip && _jsx("div", { className: "text-muted-foreground text-xs", children: groupChip }), typeof activeCount === 'number' && (_jsxs("div", { className: "text-muted-foreground text-xs", children: [_jsx("span", { className: "mr-1 inline-block size-1.5 rounded-full bg-success-500" }), labels.activeCount.replace('{n}', String(activeCount))] })), _jsxs("div", { className: "ml-auto flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1 text-xs", children: [_jsx("button", { type: "button", "aria-label": labels.previousDate, onClick: () => stepDate(-1), className: "text-muted-foreground hover:text-foreground", children: _jsx(ChevronLeft, { className: "size-3.5" }) }), _jsx("div", { className: "px-1", children: formatDateHeader(selectedDate, now) }), _jsx("button", { type: "button", "aria-label": labels.nextDate, onClick: () => stepDate(1), className: "text-muted-foreground hover:text-foreground", children: _jsx(ChevronRight, { className: "size-3.5" }) })] })] }), _jsxs("div", { className: "grid items-end gap-0", style: { gridTemplateColumns: `${labelGutterPx}px 1fr` }, children: [_jsx("div", {}), _jsx("div", { className: "relative h-5", children: ticks.map((t) => (_jsx("div", { className: "-translate-x-1/2 absolute top-0 text-muted-foreground text-xs", style: { left: `${t.position}%` }, children: t.label }, `${t.position}:${t.label}`))) })] })] }));
13
+ }
14
+ function formatDateHeader(d, referenceNow) {
15
+ const isToday = d.toDateString() === referenceNow.toDateString();
16
+ const fmt = new Intl.DateTimeFormat(undefined, {
17
+ weekday: 'short',
18
+ month: 'short',
19
+ day: 'numeric',
20
+ }).format(d);
21
+ return isToday ? `Today · ${fmt}` : fmt;
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,6 +43,14 @@
43
43
  "types": "./dist/timeline/index.d.ts",
44
44
  "default": "./dist/timeline/index.js"
45
45
  },
46
+ "./kanban": {
47
+ "types": "./dist/kanban/index.d.ts",
48
+ "import": "./dist/kanban/index.js"
49
+ },
50
+ "./list": {
51
+ "types": "./dist/list/index.d.ts",
52
+ "import": "./dist/list/index.js"
53
+ },
46
54
  "./marketing": {
47
55
  "types": "./dist/marketing/index.d.ts",
48
56
  "default": "./dist/marketing/index.js"
@@ -73,6 +81,9 @@
73
81
  "access": "public"
74
82
  },
75
83
  "peerDependencies": {
84
+ "@dnd-kit/core": "^6.3.1",
85
+ "@dnd-kit/sortable": "^10.0.0",
86
+ "@dnd-kit/utilities": "^3.2.2",
76
87
  "@react-three/fiber": "^9.0.0",
77
88
  "lucide-react": "^1.8.0",
78
89
  "next-themes": "^0.4.0",
@@ -80,6 +91,15 @@
80
91
  "react-dom": ">=19"
81
92
  },
82
93
  "peerDependenciesMeta": {
94
+ "@dnd-kit/core": {
95
+ "optional": false
96
+ },
97
+ "@dnd-kit/sortable": {
98
+ "optional": false
99
+ },
100
+ "@dnd-kit/utilities": {
101
+ "optional": false
102
+ },
83
103
  "@react-three/fiber": {
84
104
  "optional": true
85
105
  },
@@ -103,6 +123,9 @@
103
123
  "@biomejs/biome": "^2.4.12",
104
124
  "@changesets/changelog-github": "^0.5.1",
105
125
  "@changesets/cli": "^2.29.0",
126
+ "@dnd-kit/core": "^6.3.1",
127
+ "@dnd-kit/sortable": "^10.0.0",
128
+ "@dnd-kit/utilities": "^3.2.2",
106
129
  "@react-three/drei": "^10.7.7",
107
130
  "@react-three/fiber": "^9.6.0",
108
131
  "@size-limit/preset-small-lib": "^12.1.0",