@schandlergarcia/sf-web-components 1.9.37 → 1.9.39

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 (109) hide show
  1. package/package.json +4 -1
  2. package/scripts/postinstall.mjs +116 -65
  3. package/src/components/library/cards/ActionList.jsx +38 -0
  4. package/src/components/library/cards/ActivityCard.jsx +56 -0
  5. package/src/components/library/cards/BaseCard.jsx +109 -0
  6. package/src/components/library/cards/CalloutCard.jsx +37 -0
  7. package/src/components/library/cards/ChartCard.jsx +105 -0
  8. package/src/components/library/cards/FeedPanel.jsx +39 -0
  9. package/src/components/library/cards/ListCard.jsx +193 -0
  10. package/src/components/library/cards/MetricCard.jsx +109 -0
  11. package/src/components/library/cards/MetricsStrip.jsx +78 -0
  12. package/src/components/library/cards/SectionCard.jsx +83 -0
  13. package/src/components/library/cards/SemanticMetricCard.jsx +52 -0
  14. package/src/components/library/cards/SemanticMetricCardWithLoading.jsx +23 -0
  15. package/src/components/library/cards/SemanticTableCard.jsx +48 -0
  16. package/src/components/library/cards/SemanticTableCardWithLoading.jsx +22 -0
  17. package/src/components/library/cards/StatusCard.jsx +220 -0
  18. package/src/components/library/cards/TableCard.jsx +337 -0
  19. package/src/components/library/cards/WidgetCard.jsx +90 -0
  20. package/src/components/library/charts/D3Chart.jsx +109 -0
  21. package/src/components/library/charts/D3ChartTemplates.jsx +126 -0
  22. package/src/components/library/charts/GeoMap.jsx +293 -0
  23. package/src/components/library/chat/ChatBar.jsx +256 -0
  24. package/src/components/library/chat/ChatInput.jsx +89 -0
  25. package/src/components/library/chat/ChatMessage.jsx +178 -0
  26. package/src/components/library/chat/ChatMessageList.jsx +73 -0
  27. package/src/components/library/chat/ChatPanel.jsx +97 -0
  28. package/src/components/library/chat/ChatSuggestions.jsx +28 -0
  29. package/src/components/library/chat/ChatToolCall.jsx +100 -0
  30. package/src/components/library/chat/ChatTypingIndicator.jsx +23 -0
  31. package/src/components/library/chat/ChatWelcome.jsx +43 -0
  32. package/src/components/library/chat/index.jsx +10 -0
  33. package/src/components/library/chat/useChatState.jsx +130 -0
  34. package/src/components/library/data/DataModeProvider.jsx +67 -0
  35. package/src/components/library/data/DataModeToggle.jsx +36 -0
  36. package/src/components/library/data/chartDataProvider.jsx +61 -0
  37. package/src/components/library/data/filterUtils.jsx +141 -0
  38. package/src/components/library/data/useDataSource.jsx +33 -0
  39. package/src/components/library/data/usePageFilters.jsx +99 -0
  40. package/src/components/library/filters/FilterBar.jsx +95 -0
  41. package/src/components/library/filters/SearchFilter.jsx +36 -0
  42. package/src/components/library/filters/SelectFilter.jsx +55 -0
  43. package/src/components/library/filters/ToggleFilter.jsx +52 -0
  44. package/src/components/library/filters/index.jsx +4 -0
  45. package/src/components/library/forms/FormField.jsx +291 -0
  46. package/src/components/library/forms/FormModal.jsx +201 -0
  47. package/src/components/library/forms/FormRenderer.jsx +46 -0
  48. package/src/components/library/forms/FormSection.jsx +69 -0
  49. package/src/components/library/forms/index.jsx +5 -0
  50. package/src/components/library/forms/useFormState.jsx +165 -0
  51. package/src/components/library/heroui/Accordion.jsx +26 -0
  52. package/src/components/library/heroui/Alert.jsx +8 -0
  53. package/src/components/library/heroui/Badge.jsx +8 -0
  54. package/src/components/library/heroui/Breadcrumbs.jsx +22 -0
  55. package/src/components/library/heroui/Button.jsx +58 -0
  56. package/src/components/library/heroui/Card.jsx +8 -0
  57. package/src/components/library/heroui/Collapsible.jsx +42 -0
  58. package/src/components/library/heroui/DatePicker.jsx +34 -0
  59. package/src/components/library/heroui/Dialog.jsx +37 -0
  60. package/src/components/library/heroui/Drawer.jsx +32 -0
  61. package/src/components/library/heroui/Dropdown.jsx +28 -0
  62. package/src/components/library/heroui/Field.jsx +51 -0
  63. package/src/components/library/heroui/Input.jsx +6 -0
  64. package/src/components/library/heroui/Kbd.jsx +8 -0
  65. package/src/components/library/heroui/Meter.jsx +8 -0
  66. package/src/components/library/heroui/Modal.jsx +32 -0
  67. package/src/components/library/heroui/Pagination.jsx +8 -0
  68. package/src/components/library/heroui/Popover.jsx +64 -0
  69. package/src/components/library/heroui/ProgressBar.jsx +8 -0
  70. package/src/components/library/heroui/ProgressCircle.jsx +8 -0
  71. package/src/components/library/heroui/ScrollShadow.jsx +8 -0
  72. package/src/components/library/heroui/Select.jsx +37 -0
  73. package/src/components/library/heroui/Separator.jsx +8 -0
  74. package/src/components/library/heroui/Skeleton.jsx +8 -0
  75. package/src/components/library/heroui/Tabs.jsx +26 -0
  76. package/src/components/library/heroui/Toast.jsx +25 -0
  77. package/src/components/library/heroui/Toggle.jsx +14 -0
  78. package/src/components/library/heroui/Tooltip.jsx +21 -0
  79. package/src/components/library/index.jsx +146 -0
  80. package/src/components/library/layout/PageContainer.jsx +11 -0
  81. package/src/components/library/skeletons/CardSkeleton.jsx +30 -0
  82. package/src/components/library/theme/AppThemeProvider.jsx +67 -0
  83. package/src/components/library/theme/tokens.jsx +72 -0
  84. package/src/components/library/ui/Alert.jsx +80 -0
  85. package/src/components/library/ui/Avatar.jsx +44 -0
  86. package/src/components/library/ui/BreadcrumbExtras.tsx +120 -0
  87. package/src/components/library/ui/Button.jsx +61 -0
  88. package/src/components/library/ui/Card.jsx +117 -0
  89. package/src/components/library/ui/Checkbox.jsx +17 -0
  90. package/src/components/library/ui/Chip.jsx +38 -0
  91. package/src/components/library/ui/Collapsible.tsx +31 -0
  92. package/src/components/library/ui/Container.jsx +56 -0
  93. package/src/components/library/ui/DatePicker.tsx +34 -0
  94. package/src/components/library/ui/Dialog.tsx +141 -0
  95. package/src/components/library/ui/EmptyState.jsx +46 -0
  96. package/src/components/library/ui/Field.tsx +82 -0
  97. package/src/components/library/ui/FieldGroup.jsx +17 -0
  98. package/src/components/library/ui/Input.jsx +21 -0
  99. package/src/components/library/ui/Label.jsx +22 -0
  100. package/src/components/library/ui/PaginationExtras.tsx +142 -0
  101. package/src/components/library/ui/Popover.tsx +39 -0
  102. package/src/components/library/ui/Select.tsx +113 -0
  103. package/src/components/library/ui/Spinner.d.ts +10 -0
  104. package/src/components/library/ui/Spinner.jsx +64 -0
  105. package/src/components/library/ui/Text.jsx +46 -0
  106. package/src/components/library/ui/Toggle.jsx +42 -0
  107. package/src/components/workspace/ComponentRegistry.jsx +297 -0
  108. package/src/lib/index.ts +1 -0
  109. package/src/lib/utils.ts +6 -0
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ import { SparklesIcon } from "@heroicons/react/24/outline";
3
+
4
+ /**
5
+ * Quick-action prompt buttons. Place above the input or after an assistant message.
6
+ *
7
+ * @param {string[]} suggestions — prompt strings
8
+ * @param {Function} onSelect — (suggestion) => void
9
+ */
10
+ export default function ChatSuggestions({ suggestions = [], onSelect }) {
11
+ if (!suggestions.length) return null;
12
+
13
+ return (
14
+ <div className="flex flex-wrap gap-2">
15
+ {suggestions.map((text) => (
16
+ <button
17
+ key={text}
18
+ type="button"
19
+ onClick={() => onSelect?.(text)}
20
+ className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 shadow-sm transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-brand-700 dark:hover:bg-brand-950/30 dark:hover:text-brand-300"
21
+ >
22
+ <SparklesIcon className="h-3 w-3" aria-hidden="true" />
23
+ {text}
24
+ </button>
25
+ ))}
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,100 @@
1
+ import React, { useState } from "react";
2
+ import Spinner from "../ui/Spinner";
3
+ import {
4
+ WrenchScrewdriverIcon,
5
+ CheckCircleIcon,
6
+ XCircleIcon,
7
+ ChevronDownIcon,
8
+ } from "@heroicons/react/24/outline";
9
+
10
+ const STATUS_CONFIG = {
11
+ running: {
12
+ icon: <Spinner size="xs" tone="brand" label="Running" />,
13
+ text: "text-brand-600 dark:text-brand-400",
14
+ bg: "bg-brand-50 border-brand-100 dark:bg-brand-950/20 dark:border-brand-900/30",
15
+ label: "Running",
16
+ },
17
+ complete: {
18
+ icon: <CheckCircleIcon className="h-3.5 w-3.5 text-emerald-500" />,
19
+ text: "text-emerald-700 dark:text-emerald-300",
20
+ bg: "bg-emerald-50 border-emerald-100 dark:bg-emerald-950/20 dark:border-emerald-900/30",
21
+ label: "Complete",
22
+ },
23
+ error: {
24
+ icon: <XCircleIcon className="h-3.5 w-3.5 text-red-500" />,
25
+ text: "text-red-700 dark:text-red-300",
26
+ bg: "bg-red-50 border-red-100 dark:bg-red-950/20 dark:border-red-900/30",
27
+ label: "Failed",
28
+ },
29
+ };
30
+
31
+ /**
32
+ * Displays an agent tool call / function execution step.
33
+ *
34
+ * @param {Object} toolCall — { id?, name, args?, status: "running"|"complete"|"error", result? }
35
+ */
36
+ export default function ChatToolCall({ toolCall }) {
37
+ const [expanded, setExpanded] = useState(false);
38
+ const config = STATUS_CONFIG[toolCall.status] ?? STATUS_CONFIG.running;
39
+ const hasDetails = toolCall.args || toolCall.result;
40
+
41
+ return (
42
+ <div
43
+ className={[
44
+ "rounded-lg border text-xs",
45
+ config.bg,
46
+ ].join(" ")}
47
+ >
48
+ <button
49
+ type="button"
50
+ onClick={() => hasDetails && setExpanded(!expanded)}
51
+ className={[
52
+ "flex w-full items-center gap-2 px-3 py-1.5",
53
+ hasDetails ? "cursor-pointer" : "cursor-default",
54
+ ].join(" ")}
55
+ >
56
+ {config.icon}
57
+ <WrenchScrewdriverIcon className="h-3 w-3 text-slate-400 dark:text-slate-500" />
58
+ <span className={["font-medium", config.text].join(" ")}>
59
+ {toolCall.name}
60
+ </span>
61
+ <span className="text-slate-400 dark:text-slate-500">
62
+ — {config.label}
63
+ </span>
64
+ {hasDetails ? (
65
+ <ChevronDownIcon
66
+ className={[
67
+ "ml-auto h-3 w-3 text-slate-400 transition-transform dark:text-slate-500",
68
+ expanded ? "rotate-180" : "",
69
+ ].join(" ")}
70
+ />
71
+ ) : null}
72
+ </button>
73
+
74
+ {expanded && hasDetails ? (
75
+ <div className="border-t border-inherit px-3 py-2 font-mono text-[11px] leading-relaxed text-slate-600 dark:text-slate-300">
76
+ {toolCall.args ? (
77
+ <div className="mb-1">
78
+ <span className="font-sans font-semibold text-slate-500 dark:text-slate-400">
79
+ Args:{" "}
80
+ </span>
81
+ {typeof toolCall.args === "string"
82
+ ? toolCall.args
83
+ : JSON.stringify(toolCall.args, null, 2)}
84
+ </div>
85
+ ) : null}
86
+ {toolCall.result ? (
87
+ <div>
88
+ <span className="font-sans font-semibold text-slate-500 dark:text-slate-400">
89
+ Result:{" "}
90
+ </span>
91
+ {typeof toolCall.result === "string"
92
+ ? toolCall.result
93
+ : JSON.stringify(toolCall.result, null, 2)}
94
+ </div>
95
+ ) : null}
96
+ </div>
97
+ ) : null}
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,23 @@
1
+ import React from "react";
2
+ import { CpuChipIcon } from "@heroicons/react/24/solid";
3
+
4
+ /**
5
+ * Animated typing indicator shown while the agent is processing.
6
+ */
7
+ export default function ChatTypingIndicator({ label = "Thinking" }) {
8
+ return (
9
+ <div className="flex items-start gap-3">
10
+ <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 dark:bg-brand-900/40">
11
+ <CpuChipIcon className="h-4 w-4 text-brand-600 dark:text-brand-400" />
12
+ </div>
13
+ <div className="flex items-center gap-2 rounded-2xl rounded-tl-md bg-slate-100 px-4 py-3 dark:bg-slate-800">
14
+ <div className="flex gap-1">
15
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 dark:bg-slate-500 [animation-delay:0ms]" />
16
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 dark:bg-slate-500 [animation-delay:150ms]" />
17
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 dark:bg-slate-500 [animation-delay:300ms]" />
18
+ </div>
19
+ <span className="text-xs text-slate-400 dark:text-slate-500">{label}</span>
20
+ </div>
21
+ </div>
22
+ );
23
+ }
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+ import { CpuChipIcon } from "@heroicons/react/24/solid";
3
+ import ChatSuggestions from "./ChatSuggestions";
4
+
5
+ /**
6
+ * Empty-state welcome screen shown before the first message.
7
+ *
8
+ * @param {string} title — welcome heading
9
+ * @param {string} subtitle — description text
10
+ * @param {string[]} suggestions — starter prompt suggestions
11
+ * @param {Function} onSuggestion — (text) => void
12
+ * @param {React.ReactNode} icon — custom icon override
13
+ */
14
+ export default function ChatWelcome({
15
+ title = "How can I help?",
16
+ subtitle = "Ask me anything about your data, or try one of the suggestions below.",
17
+ suggestions = [],
18
+ onSuggestion,
19
+ icon,
20
+ }) {
21
+ return (
22
+ <div className="flex flex-1 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
23
+ {icon ?? (
24
+ <div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-100 dark:bg-brand-900/40">
25
+ <CpuChipIcon className="h-7 w-7 text-brand-600 dark:text-brand-400" />
26
+ </div>
27
+ )}
28
+ <div>
29
+ <h3 className="text-base font-semibold text-slate-900 dark:text-slate-50">
30
+ {title}
31
+ </h3>
32
+ <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
33
+ {subtitle}
34
+ </p>
35
+ </div>
36
+ {suggestions.length ? (
37
+ <div className="mt-2">
38
+ <ChatSuggestions suggestions={suggestions} onSelect={onSuggestion} />
39
+ </div>
40
+ ) : null}
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,10 @@
1
+ export { default as ChatPanel } from "./ChatPanel";
2
+ export { default as ChatBar } from "./ChatBar";
3
+ export { default as ChatMessageList } from "./ChatMessageList";
4
+ export { default as ChatMessage } from "./ChatMessage";
5
+ export { default as ChatInput } from "./ChatInput";
6
+ export { default as ChatTypingIndicator } from "./ChatTypingIndicator";
7
+ export { default as ChatSuggestions } from "./ChatSuggestions";
8
+ export { default as ChatToolCall } from "./ChatToolCall";
9
+ export { default as ChatWelcome } from "./ChatWelcome";
10
+ export { default as useChatState } from "./useChatState";
@@ -0,0 +1,130 @@
1
+ import { useState, useCallback, useRef } from "react";
2
+
3
+ let _nextId = 1;
4
+ function uid() {
5
+ return `msg-${Date.now()}-${_nextId++}`;
6
+ }
7
+
8
+ /**
9
+ * Core state management hook for AI chat.
10
+ *
11
+ * @param {Object} options
12
+ * @param {Array} options.initialMessages — seed the conversation
13
+ * @param {Function} options.onSend — async (userMessage, allMessages) => assistantMessage | void
14
+ * Return a message object to add it, or use appendChunk / addMessage for streaming.
15
+ *
16
+ * @returns {Object}
17
+ *
18
+ * @example
19
+ * const chat = useChatState({
20
+ * onSend: async (msg, history) => {
21
+ * const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ messages: history }) });
22
+ * const data = await res.json();
23
+ * return { role: "assistant", content: data.reply, components: data.components };
24
+ * },
25
+ * });
26
+ */
27
+ export default function useChatState({ initialMessages = [], onSend } = {}) {
28
+ const [messages, setMessages] = useState(() =>
29
+ initialMessages.map((m) => ({ id: uid(), timestamp: new Date().toISOString(), ...m }))
30
+ );
31
+ const [isLoading, setIsLoading] = useState(false);
32
+ const [isStreaming, setIsStreaming] = useState(false);
33
+ const [error, setError] = useState(null);
34
+ const onSendRef = useRef(onSend);
35
+ onSendRef.current = onSend;
36
+
37
+ const addMessage = useCallback((msg) => {
38
+ const full = { id: uid(), timestamp: new Date().toISOString(), ...msg };
39
+ setMessages((prev) => [...prev, full]);
40
+ return full.id;
41
+ }, []);
42
+
43
+ const updateMessage = useCallback((id, updates) => {
44
+ setMessages((prev) =>
45
+ prev.map((m) => (m.id === id ? { ...m, ...updates } : m))
46
+ );
47
+ }, []);
48
+
49
+ const appendChunk = useCallback((id, chunk) => {
50
+ setMessages((prev) =>
51
+ prev.map((m) =>
52
+ m.id === id ? { ...m, content: (m.content ?? "") + chunk } : m
53
+ )
54
+ );
55
+ }, []);
56
+
57
+ const removeMessage = useCallback((id) => {
58
+ setMessages((prev) => prev.filter((m) => m.id !== id));
59
+ }, []);
60
+
61
+ const clearMessages = useCallback(() => {
62
+ setMessages([]);
63
+ setError(null);
64
+ }, []);
65
+
66
+ const sendMessage = useCallback(
67
+ async (content, extra = {}) => {
68
+ if (!content?.trim()) return;
69
+ setError(null);
70
+
71
+ const userMsg = { role: "user", content: content.trim(), ...extra };
72
+ const userId = uid();
73
+ const fullUser = { ...userMsg, id: userId, timestamp: new Date().toISOString() };
74
+
75
+ setMessages((prev) => [...prev, fullUser]);
76
+ setIsLoading(true);
77
+
78
+ try {
79
+ const allMsgs = [...messages, fullUser];
80
+ const result = await onSendRef.current?.(fullUser, allMsgs, {
81
+ addMessage,
82
+ updateMessage,
83
+ appendChunk,
84
+ setStreaming: setIsStreaming,
85
+ });
86
+
87
+ if (result && typeof result === "object" && result.role) {
88
+ addMessage(result);
89
+ }
90
+ } catch (err) {
91
+ setError(err?.message ?? "Failed to send message");
92
+ addMessage({
93
+ role: "system",
94
+ content: err?.message ?? "Something went wrong. Please try again.",
95
+ isError: true,
96
+ });
97
+ } finally {
98
+ setIsLoading(false);
99
+ setIsStreaming(false);
100
+ }
101
+ },
102
+ [messages, addMessage, updateMessage, appendChunk]
103
+ );
104
+
105
+ const retryLast = useCallback(() => {
106
+ const lastUser = [...messages].reverse().find((m) => m.role === "user");
107
+ if (!lastUser) return;
108
+
109
+ const idx = messages.lastIndexOf(lastUser);
110
+ setMessages(messages.slice(0, idx));
111
+ setError(null);
112
+
113
+ sendMessage(lastUser.content);
114
+ }, [messages, sendMessage]);
115
+
116
+ return {
117
+ messages,
118
+ isLoading,
119
+ isStreaming,
120
+ error,
121
+ sendMessage,
122
+ addMessage,
123
+ updateMessage,
124
+ appendChunk,
125
+ removeMessage,
126
+ clearMessages,
127
+ retryLast,
128
+ setError,
129
+ };
130
+ }
@@ -0,0 +1,67 @@
1
+ import React from "react";
2
+
3
+ const DataModeContext = React.createContext({
4
+ mode: "sample",
5
+ isSample: true,
6
+ isLive: false,
7
+ toggle: () => {},
8
+ setMode: () => {},
9
+ });
10
+
11
+ const STORAGE_KEY = "app-data-mode";
12
+ const VALID_MODES = ["sample", "live"];
13
+
14
+ /**
15
+ * Read the current data mode from any component.
16
+ *
17
+ * @returns {{ mode: "sample"|"live", isSample: boolean, isLive: boolean, toggle: () => void, setMode: (mode) => void }}
18
+ */
19
+ export function useDataMode() {
20
+ return React.useContext(DataModeContext);
21
+ }
22
+
23
+ /**
24
+ * Provides global data-mode state (sample vs live) to the component tree.
25
+ * Persists to localStorage so the choice survives page reloads.
26
+ *
27
+ * Wrap once in _app.js alongside AppThemeProvider.
28
+ */
29
+ export default function DataModeProvider({ initialMode = "sample", children }) {
30
+ const [mode, setModeState] = React.useState(initialMode);
31
+
32
+ React.useEffect(() => {
33
+ try {
34
+ const stored = window.localStorage.getItem(STORAGE_KEY);
35
+ if (VALID_MODES.includes(stored)) setModeState(stored);
36
+ } catch {
37
+ // SSR or storage unavailable
38
+ }
39
+ }, []);
40
+
41
+ React.useEffect(() => {
42
+ try {
43
+ window.localStorage.setItem(STORAGE_KEY, mode);
44
+ } catch {
45
+ // ignore
46
+ }
47
+ }, [mode]);
48
+
49
+ const setMode = React.useCallback((m) => {
50
+ if (VALID_MODES.includes(m)) setModeState(m);
51
+ }, []);
52
+
53
+ const value = React.useMemo(
54
+ () => ({
55
+ mode,
56
+ isSample: mode === "sample",
57
+ isLive: mode === "live",
58
+ toggle: () => setModeState((m) => (m === "sample" ? "live" : "sample")),
59
+ setMode,
60
+ }),
61
+ [mode, setMode]
62
+ );
63
+
64
+ return (
65
+ <DataModeContext.Provider value={value}>{children}</DataModeContext.Provider>
66
+ );
67
+ }
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { useDataMode } from "./DataModeProvider";
3
+ import { BeakerIcon, SignalIcon } from "@heroicons/react/24/outline";
4
+
5
+ /**
6
+ * Pill toggle for switching between sample and live data modes.
7
+ * Place in the AppShell header next to the theme toggle.
8
+ */
9
+ export default function DataModeToggle({ className = "" }) {
10
+ const { mode, toggle } = useDataMode();
11
+ const isSample = mode === "sample";
12
+
13
+ return (
14
+ <button
15
+ type="button"
16
+ onClick={toggle}
17
+ className={[
18
+ "inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-semibold shadow-sm transition",
19
+ isSample
20
+ ? "border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300 dark:hover:bg-amber-950/60"
21
+ : "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300 dark:hover:bg-emerald-950/60",
22
+ className,
23
+ ]
24
+ .filter(Boolean)
25
+ .join(" ")}
26
+ aria-label={`Data mode: ${mode}. Click to switch to ${isSample ? "live" : "sample"}.`}
27
+ >
28
+ {isSample ? (
29
+ <BeakerIcon className="h-3.5 w-3.5" aria-hidden="true" />
30
+ ) : (
31
+ <SignalIcon className="h-3.5 w-3.5" aria-hidden="true" />
32
+ )}
33
+ {isSample ? "Sample" : "Live"}
34
+ </button>
35
+ );
36
+ }
@@ -0,0 +1,61 @@
1
+ // Minimal semantic data provider (seed data + lookup helpers).
2
+ // This is intentionally local/in-memory for now; later we can swap to API-backed providers.
3
+
4
+ const SEMANTIC_DATASETS = {
5
+ sales_pipeline_qtr: {
6
+ title: "Sales Pipeline (Quarter)",
7
+ metrics: [
8
+ {
9
+ metricId: "pipeline_value",
10
+ title: "Pipeline",
11
+ subtitle: "This quarter",
12
+ value: "$1.28M",
13
+ change: "+6.2%",
14
+ changeType: "positive",
15
+ color: "primary",
16
+ trend: "vs last quarter"
17
+ },
18
+ {
19
+ metricId: "win_rate",
20
+ title: "Win rate",
21
+ subtitle: "Trailing 90 days",
22
+ value: "34%",
23
+ change: "+1.1%",
24
+ changeType: "positive",
25
+ color: "success"
26
+ }
27
+ ],
28
+ table: {
29
+ title: "Opportunities",
30
+ subtitle: "Pipeline opportunities",
31
+ columns: [
32
+ { key: "name", label: "Opportunity", sortable: true },
33
+ { key: "amount", label: "Amount", type: "currency", sortable: true, mono: true },
34
+ { key: "stage", label: "Stage", sortable: true },
35
+ { key: "owner", label: "Owner", sortable: true }
36
+ ],
37
+ rows: [
38
+ { id: 1, name: "Acme Renewal", amount: 125000, stage: "Negotiation", owner: "Sam" },
39
+ { id: 2, name: "Globex Expansion", amount: 420000, stage: "Discovery", owner: "Jules" },
40
+ { id: 3, name: "Initech New Logo", amount: 98000, stage: "Proposal", owner: "Riley" },
41
+ { id: 4, name: "Umbrella Upsell", amount: 56000, stage: "Qualification", owner: "Alex" }
42
+ ]
43
+ }
44
+ }
45
+ };
46
+
47
+ export function listSemanticIds() {
48
+ return Object.keys(SEMANTIC_DATASETS);
49
+ }
50
+
51
+ export function getSemanticDataset(semanticId) {
52
+ if (!semanticId) return null;
53
+ return SEMANTIC_DATASETS[semanticId] ?? null;
54
+ }
55
+
56
+ export function getSemanticMetric(semanticId, metricId) {
57
+ const ds = getSemanticDataset(semanticId);
58
+ if (!ds?.metrics) return null;
59
+ return ds.metrics.find((m) => m.metricId === metricId) ?? null;
60
+ }
61
+
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Pure data utilities for filtering, sorting, and searching.
3
+ * Stateless — combine with usePageFilters hook for state management.
4
+ */
5
+
6
+ /**
7
+ * Text search across multiple keys.
8
+ * @param {Array} data
9
+ * @param {string} query — search string
10
+ * @param {string[]} keys — object keys to search within
11
+ * @returns {Array} filtered data
12
+ */
13
+ export function filterBySearch(data, query, keys = []) {
14
+ if (!query || !query.trim()) return data;
15
+ const q = query.trim().toLowerCase();
16
+ return data.filter((row) =>
17
+ keys.some((key) => {
18
+ const val = row?.[key];
19
+ return val != null && String(val).toLowerCase().includes(q);
20
+ })
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Filter rows where key matches a specific value.
26
+ * Pass "all" or "" to skip filtering.
27
+ * @param {Array} data
28
+ * @param {string} key — object key to match
29
+ * @param {*} value — value to match (exact, case-insensitive for strings)
30
+ * @returns {Array}
31
+ */
32
+ export function filterByValue(data, key, value) {
33
+ if (value == null || value === "" || value === "all") return data;
34
+ return data.filter((row) => {
35
+ const v = row?.[key];
36
+ if (typeof v === "string" && typeof value === "string") {
37
+ return v.toLowerCase() === value.toLowerCase();
38
+ }
39
+ return v === value;
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Filter rows where a boolean condition is met.
45
+ * When toggle is off, returns all data (no filtering).
46
+ * @param {Array} data
47
+ * @param {string} key — object key to check
48
+ * @param {boolean} isActive — whether the toggle is on
49
+ * @param {*} matchValue — value that key should equal when active (default: truthy check)
50
+ * @returns {Array}
51
+ */
52
+ export function filterByToggle(data, key, isActive, matchValue) {
53
+ if (!isActive) return data;
54
+ return data.filter((row) => {
55
+ const v = row?.[key];
56
+ if (matchValue !== undefined) return v === matchValue;
57
+ return Boolean(v);
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Filter rows where a date field falls within a range.
63
+ * @param {Array} data
64
+ * @param {string} key — object key containing date (ISO string or Date)
65
+ * @param {{ start?: Date|string, end?: Date|string }} range
66
+ * @returns {Array}
67
+ */
68
+ export function filterByDateRange(data, key, range) {
69
+ if (!range) return data;
70
+ const start = range.start ? new Date(range.start) : null;
71
+ const end = range.end ? new Date(range.end) : null;
72
+ if (!start && !end) return data;
73
+
74
+ return data.filter((row) => {
75
+ const raw = row?.[key];
76
+ if (raw == null) return false;
77
+ const d = raw instanceof Date ? raw : new Date(raw);
78
+ if (Number.isNaN(d.getTime())) return false;
79
+ if (start && d < start) return false;
80
+ if (end && d > end) return false;
81
+ return true;
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Sort data by a key.
87
+ * @param {Array} data
88
+ * @param {string} key — object key to sort by
89
+ * @param {"asc"|"desc"} direction
90
+ * @returns {Array} new sorted array
91
+ */
92
+ export function sortByKey(data, key, direction = "asc") {
93
+ if (!key) return data;
94
+ const dir = direction === "desc" ? -1 : 1;
95
+ return [...data].sort((a, b) => {
96
+ const av = a?.[key];
97
+ const bv = b?.[key];
98
+ if (av == null && bv == null) return 0;
99
+ if (av == null) return -1 * dir;
100
+ if (bv == null) return 1 * dir;
101
+ if (typeof av === "number" && typeof bv === "number") return (av - bv) * dir;
102
+ return String(av).localeCompare(String(bv)) * dir;
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Apply a set of filter definitions to data.
108
+ * Each filter in `filters` has { id, type, key/keys } and `values` holds the current state.
109
+ *
110
+ * @param {Array} data
111
+ * @param {Array} filters — filter definitions [{ id, type, key?, keys? }]
112
+ * @param {Object} values — current filter values keyed by filter id
113
+ * @returns {Array} filtered data
114
+ */
115
+ export function applyFilters(data, filters = [], values = {}) {
116
+ let result = data;
117
+
118
+ for (const filter of filters) {
119
+ const val = values[filter.id];
120
+ if (val === undefined || val === null) continue;
121
+
122
+ switch (filter.type) {
123
+ case "search":
124
+ result = filterBySearch(result, val, filter.keys ?? []);
125
+ break;
126
+ case "select":
127
+ result = filterByValue(result, filter.key, val);
128
+ break;
129
+ case "toggle":
130
+ result = filterByToggle(result, filter.key, val, filter.matchValue);
131
+ break;
132
+ case "dateRange":
133
+ result = filterByDateRange(result, filter.key, val);
134
+ break;
135
+ default:
136
+ break;
137
+ }
138
+ }
139
+
140
+ return result;
141
+ }
@@ -0,0 +1,33 @@
1
+ import { useMemo } from "react";
2
+ import { useDataMode } from "./DataModeProvider";
3
+
4
+ /**
5
+ * Select between sample and live data based on the global data mode.
6
+ *
7
+ * Values can be plain data or functions (lazy-evaluated only when active).
8
+ *
9
+ * @param {{ sample: any | () => any, live: any | () => any }} sources
10
+ * @returns {any} the resolved value for the active mode
11
+ *
12
+ * @example
13
+ * // Static data
14
+ * const incidents = useDataSource({
15
+ * sample: sampleIncidents,
16
+ * live: fetchedIncidents,
17
+ * });
18
+ *
19
+ * @example
20
+ * // Lazy — factory only runs when that mode is active
21
+ * const metrics = useDataSource({
22
+ * sample: () => generateSampleMetrics(),
23
+ * live: () => computeFromAPI(apiData),
24
+ * });
25
+ */
26
+ export default function useDataSource({ sample, live }) {
27
+ const { mode } = useDataMode();
28
+
29
+ return useMemo(() => {
30
+ const source = mode === "sample" ? sample : live;
31
+ return typeof source === "function" ? source() : source;
32
+ }, [mode, sample, live]);
33
+ }