@grackle-ai/web-components 0.107.2
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.
- package/.rush/temp/3ae72563f781afd72723475938136f113846603e.untar.log +10 -0
- package/.rush/temp/bc1d5bf9201ce71abeaeaddd096deb9b0805d703.untar.log +10 -0
- package/.rush/temp/operation/_phase_build/all.log +18 -0
- package/.rush/temp/operation/_phase_build/log-chunks.jsonl +18 -0
- package/.rush/temp/operation/_phase_build/state.json +3 -0
- package/.rush/temp/operation/_phase_test/all.log +121 -0
- package/.rush/temp/operation/_phase_test/log-chunks.jsonl +121 -0
- package/.rush/temp/operation/_phase_test/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +938 -0
- package/.storybook/main.ts +22 -0
- package/.storybook/preview.tsx +30 -0
- package/config/rig.json +4 -0
- package/config/rush-project.json +12 -0
- package/dist/index.css +1 -0
- package/dist/index.js +39221 -0
- package/eslint.config.cjs +5 -0
- package/package.json +83 -0
- package/rush-logs/web-components._phase_build.cache.log +4 -0
- package/rush-logs/web-components._phase_test.cache.log +4 -0
- package/src/components/chat/ChatInput.module.scss +81 -0
- package/src/components/chat/ChatInput.stories.tsx +91 -0
- package/src/components/chat/ChatInput.tsx +168 -0
- package/src/components/chat/index.ts +6 -0
- package/src/components/dag/DagView.module.scss +149 -0
- package/src/components/dag/DagView.stories.tsx +125 -0
- package/src/components/dag/DagView.tsx +109 -0
- package/src/components/dag/TaskNode.stories.tsx +133 -0
- package/src/components/dag/TaskNode.tsx +40 -0
- package/src/components/dag/useDagLayout.ts +139 -0
- package/src/components/display/Breadcrumbs.module.scss +71 -0
- package/src/components/display/Breadcrumbs.stories.tsx +80 -0
- package/src/components/display/Breadcrumbs.tsx +46 -0
- package/src/components/display/Button.module.scss +110 -0
- package/src/components/display/Button.stories.tsx +88 -0
- package/src/components/display/Button.tsx +40 -0
- package/src/components/display/ConfirmDialog.module.scss +67 -0
- package/src/components/display/ConfirmDialog.stories.tsx +81 -0
- package/src/components/display/ConfirmDialog.tsx +88 -0
- package/src/components/display/CopyButton.module.scss +41 -0
- package/src/components/display/CopyButton.stories.tsx +78 -0
- package/src/components/display/CopyButton.tsx +64 -0
- package/src/components/display/DemoBanner.module.scss +37 -0
- package/src/components/display/DemoBanner.stories.tsx +40 -0
- package/src/components/display/DemoBanner.tsx +23 -0
- package/src/components/display/EventHoverRow.module.scss +102 -0
- package/src/components/display/EventHoverRow.stories.tsx +99 -0
- package/src/components/display/EventHoverRow.tsx +154 -0
- package/src/components/display/EventRenderer.module.scss +272 -0
- package/src/components/display/EventRenderer.stories.tsx +186 -0
- package/src/components/display/EventRenderer.tsx +271 -0
- package/src/components/display/EventStream.module.scss +93 -0
- package/src/components/display/EventStream.stories.tsx +249 -0
- package/src/components/display/EventStream.tsx +369 -0
- package/src/components/display/FloatingActionBar.module.scss +107 -0
- package/src/components/display/FloatingActionBar.stories.tsx +122 -0
- package/src/components/display/FloatingActionBar.tsx +119 -0
- package/src/components/display/SessionAttemptSelector.module.scss +50 -0
- package/src/components/display/SessionAttemptSelector.stories.tsx +78 -0
- package/src/components/display/SessionAttemptSelector.tsx +49 -0
- package/src/components/display/SessionPicker.module.scss +200 -0
- package/src/components/display/SessionPicker.stories.tsx +169 -0
- package/src/components/display/SessionPicker.tsx +214 -0
- package/src/components/display/Skeleton.module.scss +58 -0
- package/src/components/display/Skeleton.stories.tsx +94 -0
- package/src/components/display/Skeleton.tsx +127 -0
- package/src/components/display/Spinner.module.scss +41 -0
- package/src/components/display/Spinner.stories.tsx +66 -0
- package/src/components/display/Spinner.tsx +32 -0
- package/src/components/display/SplashScreen.module.scss +20 -0
- package/src/components/display/SplashScreen.stories.tsx +26 -0
- package/src/components/display/SplashScreen.tsx +16 -0
- package/src/components/display/SplitButton.module.scss +166 -0
- package/src/components/display/SplitButton.stories.tsx +95 -0
- package/src/components/display/SplitButton.tsx +128 -0
- package/src/components/display/Tooltip.module.scss +84 -0
- package/src/components/display/Tooltip.stories.tsx +240 -0
- package/src/components/display/Tooltip.tsx +184 -0
- package/src/components/display/extractText.test.tsx +48 -0
- package/src/components/display/index.ts +20 -0
- package/src/components/editable/EditableCheckbox.stories.tsx +54 -0
- package/src/components/editable/EditableCheckbox.tsx +39 -0
- package/src/components/editable/EditableField.module.scss +135 -0
- package/src/components/editable/EditableSelect.tsx +164 -0
- package/src/components/editable/EditableTextArea.stories.tsx +50 -0
- package/src/components/editable/EditableTextArea.tsx +148 -0
- package/src/components/editable/EditableTextField.stories.tsx +62 -0
- package/src/components/editable/EditableTextField.tsx +153 -0
- package/src/components/editable/EnvironmentSelect.module.scss +17 -0
- package/src/components/editable/EnvironmentSelect.stories.tsx +61 -0
- package/src/components/editable/EnvironmentSelect.tsx +87 -0
- package/src/components/editable/index.ts +13 -0
- package/src/components/editable/useEditableField.test.tsx +233 -0
- package/src/components/editable/useEditableField.ts +173 -0
- package/src/components/index.ts +20 -0
- package/src/components/knowledge/KnowledgeDetailPanel.module.scss +162 -0
- package/src/components/knowledge/KnowledgeDetailPanel.stories.tsx +208 -0
- package/src/components/knowledge/KnowledgeDetailPanel.tsx +122 -0
- package/src/components/knowledge/KnowledgeGraph.module.scss +110 -0
- package/src/components/knowledge/KnowledgeGraph.stories.tsx +180 -0
- package/src/components/knowledge/KnowledgeGraph.tsx +455 -0
- package/src/components/knowledge/KnowledgeNav.module.scss +130 -0
- package/src/components/knowledge/KnowledgeNav.stories.tsx +108 -0
- package/src/components/knowledge/KnowledgeNav.tsx +138 -0
- package/src/components/knowledge/index.ts +3 -0
- package/src/components/layout/AppNav.module.scss +82 -0
- package/src/components/layout/AppNav.stories.tsx +115 -0
- package/src/components/layout/AppNav.tsx +133 -0
- package/src/components/layout/BottomStatusBar.module.scss +58 -0
- package/src/components/layout/BottomStatusBar.stories.tsx +35 -0
- package/src/components/layout/BottomStatusBar.tsx +206 -0
- package/src/components/layout/Sidebar.module.scss +60 -0
- package/src/components/layout/Sidebar.stories.tsx +46 -0
- package/src/components/layout/Sidebar.tsx +84 -0
- package/src/components/layout/StatusBar.module.scss +108 -0
- package/src/components/layout/StatusBar.stories.tsx +119 -0
- package/src/components/layout/StatusBar.tsx +70 -0
- package/src/components/layout/index.ts +9 -0
- package/src/components/lists/EnvironmentNav.module.scss +118 -0
- package/src/components/lists/EnvironmentNav.stories.tsx +121 -0
- package/src/components/lists/EnvironmentNav.tsx +133 -0
- package/src/components/lists/FindingsNav.module.scss +126 -0
- package/src/components/lists/FindingsNav.tsx +146 -0
- package/src/components/lists/TaskList.module.scss +206 -0
- package/src/components/lists/TaskList.stories.tsx +401 -0
- package/src/components/lists/TaskList.tsx +509 -0
- package/src/components/lists/index.ts +6 -0
- package/src/components/lists/listHelpers.tsx +130 -0
- package/src/components/notifications/Callout.module.scss +83 -0
- package/src/components/notifications/Callout.stories.tsx +81 -0
- package/src/components/notifications/Callout.tsx +64 -0
- package/src/components/notifications/Toast.module.scss +86 -0
- package/src/components/notifications/Toast.stories.tsx +71 -0
- package/src/components/notifications/Toast.tsx +51 -0
- package/src/components/notifications/ToastContainer.module.scss +23 -0
- package/src/components/notifications/ToastContainer.stories.tsx +66 -0
- package/src/components/notifications/ToastContainer.tsx +29 -0
- package/src/components/notifications/UpdateBanner.stories.tsx +77 -0
- package/src/components/notifications/UpdateBanner.test.tsx +64 -0
- package/src/components/notifications/UpdateBanner.tsx +44 -0
- package/src/components/notifications/index.ts +8 -0
- package/src/components/panels/AboutPanel.stories.tsx +70 -0
- package/src/components/panels/AboutPanel.tsx +66 -0
- package/src/components/panels/AppearancePanel.stories.tsx +45 -0
- package/src/components/panels/AppearancePanel.tsx +97 -0
- package/src/components/panels/CredentialProvidersPanel.stories.tsx +62 -0
- package/src/components/panels/CredentialProvidersPanel.tsx +111 -0
- package/src/components/panels/EnvironmentEditPanel.module.scss +170 -0
- package/src/components/panels/EnvironmentEditPanel.stories.tsx +206 -0
- package/src/components/panels/EnvironmentEditPanel.tsx +785 -0
- package/src/components/panels/FindingsPanel.module.scss +94 -0
- package/src/components/panels/FindingsPanel.stories.tsx +109 -0
- package/src/components/panels/FindingsPanel.tsx +76 -0
- package/src/components/panels/KeyboardShortcutsPanel.module.scss +65 -0
- package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +40 -0
- package/src/components/panels/KeyboardShortcutsPanel.tsx +104 -0
- package/src/components/panels/PluginsPanel.tsx +77 -0
- package/src/components/panels/SettingsPanel.module.scss +336 -0
- package/src/components/panels/TaskActionButtons.module.scss +22 -0
- package/src/components/panels/TaskActionButtons.stories.tsx +125 -0
- package/src/components/panels/TaskActionButtons.tsx +87 -0
- package/src/components/panels/TaskEditPanel.module.scss +202 -0
- package/src/components/panels/TaskEditPanel.stories.tsx +75 -0
- package/src/components/panels/TaskEditPanel.tsx +328 -0
- package/src/components/panels/TaskOverviewPanel.module.scss +236 -0
- package/src/components/panels/TaskOverviewPanel.stories.tsx +219 -0
- package/src/components/panels/TaskOverviewPanel.tsx +270 -0
- package/src/components/panels/TokensPanel.stories.tsx +131 -0
- package/src/components/panels/TokensPanel.tsx +143 -0
- package/src/components/panels/WorkpadPanel.module.scss +39 -0
- package/src/components/panels/WorkpadPanel.stories.tsx +56 -0
- package/src/components/panels/WorkpadPanel.tsx +63 -0
- package/src/components/panels/index.ts +13 -0
- package/src/components/personas/McpToolSelector.module.scss +109 -0
- package/src/components/personas/McpToolSelector.stories.tsx +129 -0
- package/src/components/personas/McpToolSelector.tsx +180 -0
- package/src/components/personas/PersonaManager.module.scss +233 -0
- package/src/components/personas/PersonaManager.stories.tsx +139 -0
- package/src/components/personas/PersonaManager.tsx +122 -0
- package/src/components/schedules/ScheduleManager.module.scss +98 -0
- package/src/components/schedules/ScheduleManager.stories.tsx +78 -0
- package/src/components/schedules/ScheduleManager.tsx +160 -0
- package/src/components/settings/SettingsNav.module.scss +82 -0
- package/src/components/settings/SettingsNav.stories.tsx +83 -0
- package/src/components/settings/SettingsNav.tsx +104 -0
- package/src/components/streams/StreamDetailPanel.module.scss +206 -0
- package/src/components/streams/StreamDetailPanel.stories.tsx +132 -0
- package/src/components/streams/StreamDetailPanel.tsx +119 -0
- package/src/components/streams/StreamList.module.scss +92 -0
- package/src/components/streams/StreamList.stories.tsx +99 -0
- package/src/components/streams/StreamList.tsx +114 -0
- package/src/components/streams/index.ts +10 -0
- package/src/components/tools/AgentToolCard.module.scss +118 -0
- package/src/components/tools/AgentToolCard.stories.tsx +304 -0
- package/src/components/tools/AgentToolCard.tsx +247 -0
- package/src/components/tools/FileEditCard.stories.tsx +138 -0
- package/src/components/tools/FileEditCard.tsx +160 -0
- package/src/components/tools/FileReadCard.stories.tsx +120 -0
- package/src/components/tools/FileReadCard.tsx +106 -0
- package/src/components/tools/FindingCard.stories.tsx +124 -0
- package/src/components/tools/FindingCard.tsx +178 -0
- package/src/components/tools/GenericToolCard.stories.tsx +80 -0
- package/src/components/tools/GenericToolCard.tsx +111 -0
- package/src/components/tools/IpcCard.stories.tsx +129 -0
- package/src/components/tools/IpcCard.tsx +178 -0
- package/src/components/tools/KnowledgeCard.stories.tsx +112 -0
- package/src/components/tools/KnowledgeCard.tsx +165 -0
- package/src/components/tools/MetadataCard.stories.tsx +32 -0
- package/src/components/tools/MetadataCard.tsx +39 -0
- package/src/components/tools/SearchCard.stories.tsx +74 -0
- package/src/components/tools/SearchCard.tsx +86 -0
- package/src/components/tools/ShellCard.stories.tsx +112 -0
- package/src/components/tools/ShellCard.tsx +106 -0
- package/src/components/tools/TaskCard.stories.tsx +123 -0
- package/src/components/tools/TaskCard.tsx +203 -0
- package/src/components/tools/TodoCard.module.scss +131 -0
- package/src/components/tools/TodoCard.stories.tsx +202 -0
- package/src/components/tools/TodoCard.tsx +200 -0
- package/src/components/tools/ToolCard.stories.tsx +177 -0
- package/src/components/tools/ToolCard.tsx +60 -0
- package/src/components/tools/ToolCardProps.ts +20 -0
- package/src/components/tools/ToolSearchCard.stories.tsx +81 -0
- package/src/components/tools/ToolSearchCard.tsx +86 -0
- package/src/components/tools/WorkpadCard.stories.tsx +106 -0
- package/src/components/tools/WorkpadCard.tsx +125 -0
- package/src/components/tools/classifyTool.test.ts +44 -0
- package/src/components/tools/classifyTool.ts +134 -0
- package/src/components/tools/parseDiff.ts +95 -0
- package/src/components/tools/parseShellOutput.ts +28 -0
- package/src/components/tools/toolCardHelpers.test.ts +53 -0
- package/src/components/tools/toolCards.module.scss +234 -0
- package/src/components/workspace/WorkspaceBoard.module.scss +238 -0
- package/src/components/workspace/WorkspaceBoard.stories.tsx +240 -0
- package/src/components/workspace/WorkspaceBoard.tsx +232 -0
- package/src/components/workspace/WorkspaceFormFields.module.scss +79 -0
- package/src/components/workspace/WorkspaceFormFields.stories.tsx +133 -0
- package/src/components/workspace/WorkspaceFormFields.tsx +185 -0
- package/src/context/GrackleContext.ts +28 -0
- package/src/context/GrackleContextTypes.ts +64 -0
- package/src/context/SidebarContext.tsx +53 -0
- package/src/context/ThemeContext.tsx +21 -0
- package/src/context/ToastContext.tsx +56 -0
- package/src/hooks/types.ts +864 -0
- package/src/hooks/useEventSelection.test.ts +204 -0
- package/src/hooks/useEventSelection.ts +158 -0
- package/src/hooks/useSmartScroll.ts +151 -0
- package/src/hooks/useTheme.ts +228 -0
- package/src/index.ts +210 -0
- package/src/mocks/MockGrackleProvider.tsx +1397 -0
- package/src/mocks/mockData.ts +1966 -0
- package/src/mocks/mockKnowledgeData.ts +294 -0
- package/src/scss.d.ts +12 -0
- package/src/styles/global.scss +244 -0
- package/src/styles/mixins.scss +278 -0
- package/src/styles/prism-theme.scss +148 -0
- package/src/styles/theme.scss +1102 -0
- package/src/test-utils/storybook-decorators.tsx +50 -0
- package/src/test-utils/storybook-helpers.ts +262 -0
- package/src/themes.ts +142 -0
- package/src/utils/boardColumns.ts +141 -0
- package/src/utils/breadcrumbs.test.ts +285 -0
- package/src/utils/breadcrumbs.ts +222 -0
- package/src/utils/dashboard.test.ts +156 -0
- package/src/utils/dashboard.ts +195 -0
- package/src/utils/eventContent.test.ts +353 -0
- package/src/utils/eventContent.ts +209 -0
- package/src/utils/findingCategory.ts +33 -0
- package/src/utils/format.ts +27 -0
- package/src/utils/iconSize.ts +18 -0
- package/src/utils/navigation.ts +205 -0
- package/src/utils/route-config.test.ts +128 -0
- package/src/utils/scrollUtils.test.ts +65 -0
- package/src/utils/scrollUtils.ts +49 -0
- package/src/utils/sessionEvents.test.ts +302 -0
- package/src/utils/sessionEvents.ts +233 -0
- package/src/utils/taskStatus.tsx +137 -0
- package/src/utils/time.ts +92 -0
- package/tsconfig.json +8 -0
- package/vite.config.ts +20 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session picker dialog for forwarding messages.
|
|
3
|
+
*
|
|
4
|
+
* Displays a filterable list of active sessions (excluding the current one)
|
|
5
|
+
* so the user can choose a target for a forwarded message.
|
|
6
|
+
* Pure presentational component -- no useGrackle().
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef, useState, type JSX } from "react";
|
|
10
|
+
import { X, Search } from "lucide-react";
|
|
11
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
12
|
+
import type { Session, Environment, PersonaData } from "../../hooks/types.js";
|
|
13
|
+
import { ICON_SM, ICON_MD } from "../../utils/iconSize.js";
|
|
14
|
+
import styles from "./SessionPicker.module.scss";
|
|
15
|
+
|
|
16
|
+
/** A session entry enriched with its environment display name. */
|
|
17
|
+
export interface SessionPickerEntry {
|
|
18
|
+
/** The session. */
|
|
19
|
+
session: Session;
|
|
20
|
+
/** Display name of the session's environment. */
|
|
21
|
+
environmentName: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Props for the SessionPicker component. */
|
|
25
|
+
export interface SessionPickerProps {
|
|
26
|
+
/** Whether the picker is visible. */
|
|
27
|
+
isOpen: boolean;
|
|
28
|
+
/** Active sessions to display (already filtered: active status, not current session). */
|
|
29
|
+
sessions: Session[];
|
|
30
|
+
/** Environments for name lookup. */
|
|
31
|
+
environments: Environment[];
|
|
32
|
+
/** Personas for name lookup (optional — persona name is shown when available). */
|
|
33
|
+
personas?: PersonaData[];
|
|
34
|
+
/** Called when the user selects a target session. */
|
|
35
|
+
onSelect: (sessionId: string) => void;
|
|
36
|
+
/** Called when the user dismisses the picker without selecting. */
|
|
37
|
+
onCancel: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Returns a short status badge label for a session. */
|
|
41
|
+
function statusLabel(status: string): string {
|
|
42
|
+
if (status === "running") {
|
|
43
|
+
return "running";
|
|
44
|
+
}
|
|
45
|
+
if (status === "idle") {
|
|
46
|
+
return "idle";
|
|
47
|
+
}
|
|
48
|
+
return status;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Modal dialog listing active sessions for the forward-message feature.
|
|
53
|
+
*
|
|
54
|
+
* The list is filterable by environment name or prompt snippet. If there are
|
|
55
|
+
* no active sessions the picker is not rendered (parent should keep it closed).
|
|
56
|
+
*/
|
|
57
|
+
export function SessionPicker({
|
|
58
|
+
isOpen,
|
|
59
|
+
sessions,
|
|
60
|
+
environments,
|
|
61
|
+
personas,
|
|
62
|
+
onSelect,
|
|
63
|
+
onCancel,
|
|
64
|
+
}: SessionPickerProps): JSX.Element {
|
|
65
|
+
const [filter, setFilter] = useState("");
|
|
66
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
67
|
+
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
68
|
+
|
|
69
|
+
// Focus the dialog when it opens so Escape is reliably captured
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (isOpen) {
|
|
72
|
+
// Filter input has autoFocus when shown; otherwise fall back to close button
|
|
73
|
+
if (sessions.length <= 4) {
|
|
74
|
+
closeButtonRef.current?.focus();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}, [isOpen, sessions.length]);
|
|
78
|
+
|
|
79
|
+
// Build lookup for environment names
|
|
80
|
+
const envNameById = new Map<string, string>(
|
|
81
|
+
environments.map((e) => [e.id, e.displayName]),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Build lookup for persona names
|
|
85
|
+
const personaNameById = new Map<string, string>(
|
|
86
|
+
(personas ?? []).map((p) => [p.id, p.name]),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const entries: SessionPickerEntry[] = sessions.map((s) => ({
|
|
90
|
+
session: s,
|
|
91
|
+
environmentName: envNameById.get(s.environmentId) ?? s.environmentId,
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
const showFilter = sessions.length > 4;
|
|
95
|
+
|
|
96
|
+
const filtered = filter.trim()
|
|
97
|
+
? entries.filter(
|
|
98
|
+
({ session, environmentName }) =>
|
|
99
|
+
environmentName.toLowerCase().includes(filter.toLowerCase()) ||
|
|
100
|
+
session.prompt.toLowerCase().includes(filter.toLowerCase()),
|
|
101
|
+
)
|
|
102
|
+
: entries;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<AnimatePresence>
|
|
106
|
+
{isOpen && (
|
|
107
|
+
<motion.div
|
|
108
|
+
className={styles.overlay}
|
|
109
|
+
initial={{ opacity: 0 }}
|
|
110
|
+
animate={{ opacity: 1 }}
|
|
111
|
+
exit={{ opacity: 0 }}
|
|
112
|
+
transition={{ duration: 0.15 }}
|
|
113
|
+
onClick={onCancel}
|
|
114
|
+
onKeyDown={(e) => { if (e.key === "Escape") { onCancel(); } }}
|
|
115
|
+
role="dialog"
|
|
116
|
+
aria-modal="true"
|
|
117
|
+
aria-label="Forward to session"
|
|
118
|
+
data-testid="session-picker-overlay"
|
|
119
|
+
>
|
|
120
|
+
<motion.div
|
|
121
|
+
ref={dialogRef}
|
|
122
|
+
className={styles.dialog}
|
|
123
|
+
initial={{ opacity: 0, scale: 0.93, y: -10 }}
|
|
124
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
125
|
+
exit={{ opacity: 0, scale: 0.93, y: -10 }}
|
|
126
|
+
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
|
127
|
+
onClick={(e) => { e.stopPropagation(); }}
|
|
128
|
+
data-testid="session-picker-dialog"
|
|
129
|
+
>
|
|
130
|
+
<div className={styles.header}>
|
|
131
|
+
<h3 className={styles.title}>Forward to session</h3>
|
|
132
|
+
<button
|
|
133
|
+
ref={closeButtonRef}
|
|
134
|
+
type="button"
|
|
135
|
+
className={styles.closeButton}
|
|
136
|
+
onClick={onCancel}
|
|
137
|
+
aria-label="Close session picker"
|
|
138
|
+
data-testid="session-picker-close"
|
|
139
|
+
>
|
|
140
|
+
<X size={ICON_SM} aria-hidden="true" />
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{showFilter && (
|
|
145
|
+
<div className={styles.filterRow}>
|
|
146
|
+
<Search size={ICON_SM} className={styles.searchIcon} aria-hidden="true" />
|
|
147
|
+
<input
|
|
148
|
+
type="text"
|
|
149
|
+
className={styles.filterInput}
|
|
150
|
+
placeholder="Filter sessions..."
|
|
151
|
+
value={filter}
|
|
152
|
+
onChange={(e) => { setFilter(e.target.value); }}
|
|
153
|
+
data-testid="session-picker-filter"
|
|
154
|
+
autoFocus
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{sessions.length === 0 ? (
|
|
160
|
+
<div className={styles.noSessions} data-testid="session-picker-no-sessions">
|
|
161
|
+
<Search size={ICON_MD} aria-hidden="true" />
|
|
162
|
+
<p>No active sessions to forward to.</p>
|
|
163
|
+
</div>
|
|
164
|
+
) : (
|
|
165
|
+
<ul className={styles.list} data-testid="session-picker-list">
|
|
166
|
+
{filtered.length === 0 ? (
|
|
167
|
+
<li className={styles.emptyItem} data-testid="session-picker-empty">
|
|
168
|
+
No matching sessions
|
|
169
|
+
</li>
|
|
170
|
+
) : (
|
|
171
|
+
filtered.map(({ session, environmentName }) => {
|
|
172
|
+
const personaName = session.personaId
|
|
173
|
+
? (personaNameById.get(session.personaId) ?? undefined)
|
|
174
|
+
: undefined;
|
|
175
|
+
return (
|
|
176
|
+
<li key={session.id}>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
className={styles.sessionRow}
|
|
180
|
+
onClick={() => { onSelect(session.id); }}
|
|
181
|
+
data-testid={`session-picker-item-${session.id}`}
|
|
182
|
+
>
|
|
183
|
+
<div className={styles.sessionMain}>
|
|
184
|
+
<span className={styles.envName}>{environmentName}</span>
|
|
185
|
+
{personaName !== undefined && (
|
|
186
|
+
<span className={styles.personaName} data-testid={`session-picker-persona-${session.id}`}>
|
|
187
|
+
{personaName}
|
|
188
|
+
</span>
|
|
189
|
+
)}
|
|
190
|
+
<span
|
|
191
|
+
className={`${styles.statusBadge} ${styles[`status_${session.status}`] ?? styles.status_other}`}
|
|
192
|
+
data-testid={`session-picker-status-${session.id}`}
|
|
193
|
+
>
|
|
194
|
+
{statusLabel(session.status)}
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
<div className={styles.sessionPrompt}>
|
|
198
|
+
{session.prompt.length > 80
|
|
199
|
+
? `${session.prompt.slice(0, 80)}...`
|
|
200
|
+
: session.prompt}
|
|
201
|
+
</div>
|
|
202
|
+
</button>
|
|
203
|
+
</li>
|
|
204
|
+
);
|
|
205
|
+
})
|
|
206
|
+
)}
|
|
207
|
+
</ul>
|
|
208
|
+
)}
|
|
209
|
+
</motion.div>
|
|
210
|
+
</motion.div>
|
|
211
|
+
)}
|
|
212
|
+
</AnimatePresence>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Skeleton — shimmer loading placeholders
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
@use '../../styles/mixins' as *;
|
|
6
|
+
|
|
7
|
+
// ─── Base shimmer block ──────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
@keyframes shimmer {
|
|
10
|
+
0% {
|
|
11
|
+
background-position: -200% 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
100% {
|
|
15
|
+
background-position: 200% 0;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.skeleton {
|
|
20
|
+
display: block;
|
|
21
|
+
background: linear-gradient(
|
|
22
|
+
90deg,
|
|
23
|
+
var(--bg-inset) 25%,
|
|
24
|
+
var(--bg-surface) 50%,
|
|
25
|
+
var(--bg-inset) 75%
|
|
26
|
+
);
|
|
27
|
+
background-size: 200% 100%;
|
|
28
|
+
animation: shimmer 1.5s ease-in-out infinite;
|
|
29
|
+
border-radius: var(--radius-sm);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.circular {
|
|
33
|
+
border-radius: 50%;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@media (prefers-reduced-motion: reduce) {
|
|
37
|
+
.skeleton {
|
|
38
|
+
animation: none;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── SkeletonText ────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
.textContainer {
|
|
45
|
+
display: flex;
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── SkeletonCard ────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
.card {
|
|
52
|
+
@include surface-card;
|
|
53
|
+
|
|
54
|
+
display: flex;
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
gap: var(--space-md);
|
|
57
|
+
padding: var(--space-lg);
|
|
58
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { expect } from "@storybook/test";
|
|
3
|
+
import { Skeleton, SkeletonText, SkeletonCard } from "./Skeleton.js";
|
|
4
|
+
|
|
5
|
+
// ─── Skeleton (base) ─────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const skeletonMeta: Meta<typeof Skeleton> = {
|
|
8
|
+
component: Skeleton,
|
|
9
|
+
title: "Primitives/Display/Skeleton",
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
};
|
|
12
|
+
export default skeletonMeta;
|
|
13
|
+
type Story = StoryObj<typeof skeletonMeta>;
|
|
14
|
+
|
|
15
|
+
/** Default full-width skeleton block. */
|
|
16
|
+
export const Default: Story = {
|
|
17
|
+
play: async ({ canvas }) => {
|
|
18
|
+
const el = canvas.getByTestId("skeleton");
|
|
19
|
+
await expect(el).toBeInTheDocument();
|
|
20
|
+
await expect(el).toHaveAttribute("aria-hidden", "true");
|
|
21
|
+
await expect(el.className).toContain("skeleton");
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Circular skeleton (avatar placeholder). */
|
|
26
|
+
export const Circular: Story = {
|
|
27
|
+
args: { variant: "circular", width: "48px", height: "48px" },
|
|
28
|
+
play: async ({ canvas }) => {
|
|
29
|
+
const el = canvas.getByTestId("skeleton");
|
|
30
|
+
await expect(el.className).toContain("circular");
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Custom width and height. */
|
|
35
|
+
export const CustomSize: Story = {
|
|
36
|
+
args: { width: "200px", height: "2rem" },
|
|
37
|
+
play: async ({ canvas }) => {
|
|
38
|
+
const el = canvas.getByTestId("skeleton");
|
|
39
|
+
await expect(el.style.width).toBe("200px");
|
|
40
|
+
await expect(el.style.height).toBe("2rem");
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ─── SkeletonText ────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/** Multi-line text placeholder (3 lines, last line shorter). */
|
|
47
|
+
export const Text: Story = {
|
|
48
|
+
render: (args) => <SkeletonText {...args} />,
|
|
49
|
+
play: async ({ canvas }) => {
|
|
50
|
+
const container = canvas.getByTestId("skeleton-text");
|
|
51
|
+
await expect(container).toBeInTheDocument();
|
|
52
|
+
const lines = container.querySelectorAll("[data-testid='skeleton']");
|
|
53
|
+
await expect(lines.length).toBe(3);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Single-line text placeholder. */
|
|
58
|
+
export const TextSingleLine: Story = {
|
|
59
|
+
render: () => <SkeletonText lines={1} />,
|
|
60
|
+
play: async ({ canvas }) => {
|
|
61
|
+
const container = canvas.getByTestId("skeleton-text");
|
|
62
|
+
const lines = container.querySelectorAll("[data-testid='skeleton']");
|
|
63
|
+
await expect(lines.length).toBe(1);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ─── SkeletonCard ────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** Card-shaped skeleton with title and body text. */
|
|
70
|
+
export const Card: Story = {
|
|
71
|
+
render: (args) => <SkeletonCard {...args} />,
|
|
72
|
+
play: async ({ canvas }) => {
|
|
73
|
+
const card = canvas.getByTestId("skeleton-card");
|
|
74
|
+
await expect(card).toBeInTheDocument();
|
|
75
|
+
// Title skeleton + text container with 2 lines = 3 skeleton elements total
|
|
76
|
+
const skeletons = card.querySelectorAll("[data-testid='skeleton']");
|
|
77
|
+
await expect(skeletons.length).toBe(3);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Grid of skeleton cards (composition demo). */
|
|
82
|
+
export const CardGrid: Story = {
|
|
83
|
+
render: () => (
|
|
84
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "var(--space-lg)" }}>
|
|
85
|
+
<SkeletonCard />
|
|
86
|
+
<SkeletonCard />
|
|
87
|
+
<SkeletonCard />
|
|
88
|
+
</div>
|
|
89
|
+
),
|
|
90
|
+
play: async ({ canvas }) => {
|
|
91
|
+
const cards = canvas.getAllByTestId("skeleton-card");
|
|
92
|
+
await expect(cards.length).toBe(3);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { JSX } from "react";
|
|
2
|
+
import styles from "./Skeleton.module.scss";
|
|
3
|
+
|
|
4
|
+
// ─── Skeleton (base shimmer block) ───────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/** Shape variant for the Skeleton component. */
|
|
7
|
+
type SkeletonVariant = "rectangular" | "circular";
|
|
8
|
+
|
|
9
|
+
/** Props for the {@link Skeleton} component. */
|
|
10
|
+
interface SkeletonProps {
|
|
11
|
+
/** CSS width. Defaults to `"100%"`. */
|
|
12
|
+
width?: string;
|
|
13
|
+
/** CSS height. Defaults to `"1rem"`. */
|
|
14
|
+
height?: string;
|
|
15
|
+
/** CSS border-radius override. Ignored when `variant` is `"circular"`. */
|
|
16
|
+
borderRadius?: string;
|
|
17
|
+
/** Shape variant. `"circular"` forces 50% border-radius. Defaults to `"rectangular"`. */
|
|
18
|
+
variant?: SkeletonVariant;
|
|
19
|
+
/** Additional CSS class name. */
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Animated shimmer placeholder that indicates loading content.
|
|
25
|
+
* Renders a decorative `<div>` with a gradient sweep animation.
|
|
26
|
+
*/
|
|
27
|
+
export function Skeleton({
|
|
28
|
+
width = "100%",
|
|
29
|
+
height = "1rem",
|
|
30
|
+
borderRadius,
|
|
31
|
+
variant = "rectangular",
|
|
32
|
+
className,
|
|
33
|
+
}: SkeletonProps): JSX.Element {
|
|
34
|
+
const classNames = [
|
|
35
|
+
styles.skeleton,
|
|
36
|
+
variant === "circular" ? styles.circular : "",
|
|
37
|
+
className ?? "",
|
|
38
|
+
].filter(Boolean).join(" ");
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
className={classNames}
|
|
43
|
+
style={{
|
|
44
|
+
width,
|
|
45
|
+
height,
|
|
46
|
+
...(borderRadius && variant !== "circular" ? { borderRadius } : {}),
|
|
47
|
+
}}
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
data-testid="skeleton"
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── SkeletonText (multi-line text placeholder) ──────────────────────────────
|
|
55
|
+
|
|
56
|
+
/** Props for the {@link SkeletonText} component. */
|
|
57
|
+
interface SkeletonTextProps {
|
|
58
|
+
/** Number of text lines. Defaults to `3`. */
|
|
59
|
+
lines?: number;
|
|
60
|
+
/** Width of the last line. Defaults to `"60%"`. */
|
|
61
|
+
lastLineWidth?: string;
|
|
62
|
+
/** Height of each line. Defaults to `"0.75rem"`. */
|
|
63
|
+
lineHeight?: string;
|
|
64
|
+
/** Gap between lines. Defaults to `"var(--space-sm)"`. */
|
|
65
|
+
gap?: string;
|
|
66
|
+
/** Additional CSS class name. */
|
|
67
|
+
className?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Multi-line skeleton text placeholder. Renders `lines` shimmer blocks
|
|
72
|
+
* with the last line at a shorter width to simulate trailing text.
|
|
73
|
+
*/
|
|
74
|
+
export function SkeletonText({
|
|
75
|
+
lines = 3,
|
|
76
|
+
lastLineWidth = "60%",
|
|
77
|
+
lineHeight = "0.75rem",
|
|
78
|
+
gap = "var(--space-sm)",
|
|
79
|
+
className,
|
|
80
|
+
}: SkeletonTextProps): JSX.Element {
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className={`${styles.textContainer} ${className ?? ""}`}
|
|
84
|
+
style={{ gap }}
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
data-testid="skeleton-text"
|
|
87
|
+
>
|
|
88
|
+
{Array.from({ length: lines }, (_, i) => (
|
|
89
|
+
<Skeleton
|
|
90
|
+
key={i}
|
|
91
|
+
width={i === lines - 1 && lines > 1 ? lastLineWidth : "100%"}
|
|
92
|
+
height={lineHeight}
|
|
93
|
+
/>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── SkeletonCard (card-shaped placeholder) ──────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** Props for the {@link SkeletonCard} component. */
|
|
102
|
+
interface SkeletonCardProps {
|
|
103
|
+
/** Number of body text lines inside the card. Defaults to `2`. */
|
|
104
|
+
lines?: number;
|
|
105
|
+
/** Additional CSS class name. */
|
|
106
|
+
className?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Card-shaped skeleton placeholder matching the standard card layout.
|
|
111
|
+
* Contains a title-width shimmer block and body text lines.
|
|
112
|
+
*/
|
|
113
|
+
export function SkeletonCard({
|
|
114
|
+
lines = 2,
|
|
115
|
+
className,
|
|
116
|
+
}: SkeletonCardProps): JSX.Element {
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
className={`${styles.card} ${className ?? ""}`}
|
|
120
|
+
aria-hidden="true"
|
|
121
|
+
data-testid="skeleton-card"
|
|
122
|
+
>
|
|
123
|
+
<Skeleton width="40%" height="1.25rem" />
|
|
124
|
+
<SkeletonText lines={lines} />
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Spinner — inline loading indicator
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
.spinner {
|
|
6
|
+
display: inline-block;
|
|
7
|
+
width: 12px;
|
|
8
|
+
height: 12px;
|
|
9
|
+
border: 2px solid transparent;
|
|
10
|
+
border-top-color: currentColor;
|
|
11
|
+
border-right-color: currentColor;
|
|
12
|
+
border-radius: 50%;
|
|
13
|
+
animation: spin 0.7s linear infinite;
|
|
14
|
+
vertical-align: middle;
|
|
15
|
+
flex-shrink: 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Size variants
|
|
19
|
+
.sm {
|
|
20
|
+
width: 10px;
|
|
21
|
+
height: 10px;
|
|
22
|
+
border-width: 1.5px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.md {
|
|
26
|
+
width: 12px;
|
|
27
|
+
height: 12px;
|
|
28
|
+
border-width: 2px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.lg {
|
|
32
|
+
width: 16px;
|
|
33
|
+
height: 16px;
|
|
34
|
+
border-width: 2px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.xl {
|
|
38
|
+
width: 48px;
|
|
39
|
+
height: 48px;
|
|
40
|
+
border-width: 4px;
|
|
41
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { expect } from "@storybook/test";
|
|
3
|
+
import { Spinner } from "./Spinner.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Spinner> = {
|
|
6
|
+
component: Spinner,
|
|
7
|
+
title: "Primitives/Display/Spinner",
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
};
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
/** Default medium spinner. */
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
play: async ({ canvas }) => {
|
|
16
|
+
const spinner = canvas.getByLabelText("Loading");
|
|
17
|
+
await expect(spinner).toBeInTheDocument();
|
|
18
|
+
await expect(spinner.className).toContain("md");
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Small spinner. */
|
|
23
|
+
export const Small: Story = {
|
|
24
|
+
args: { size: "sm" },
|
|
25
|
+
play: async ({ canvas }) => {
|
|
26
|
+
const spinner = canvas.getByLabelText("Loading");
|
|
27
|
+
await expect(spinner.className).toContain("sm");
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Large spinner. */
|
|
32
|
+
export const Large: Story = {
|
|
33
|
+
args: { size: "lg" },
|
|
34
|
+
play: async ({ canvas }) => {
|
|
35
|
+
const spinner = canvas.getByLabelText("Loading");
|
|
36
|
+
await expect(spinner.className).toContain("lg");
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Extra large spinner. */
|
|
41
|
+
export const ExtraLarge: Story = {
|
|
42
|
+
args: { size: "xl" },
|
|
43
|
+
play: async ({ canvas }) => {
|
|
44
|
+
const spinner = canvas.getByLabelText("Loading");
|
|
45
|
+
await expect(spinner.className).toContain("xl");
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Spinner with a custom accessible label. */
|
|
50
|
+
export const WithLabel: Story = {
|
|
51
|
+
args: { label: "Saving changes" },
|
|
52
|
+
play: async ({ canvas }) => {
|
|
53
|
+
const spinner = canvas.getByLabelText("Saving changes");
|
|
54
|
+
await expect(spinner).toBeInTheDocument();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Spinner as a live region announces changes to screen readers. */
|
|
59
|
+
export const LiveRegion: Story = {
|
|
60
|
+
args: { liveRegion: true, label: "Processing" },
|
|
61
|
+
play: async ({ canvas }) => {
|
|
62
|
+
const spinner = canvas.getByRole("status");
|
|
63
|
+
await expect(spinner).toBeInTheDocument();
|
|
64
|
+
await expect(spinner).toHaveAttribute("aria-label", "Processing");
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { JSX } from "react";
|
|
2
|
+
import styles from "./Spinner.module.scss";
|
|
3
|
+
|
|
4
|
+
/** Size variants for the Spinner component. */
|
|
5
|
+
type SpinnerSize = "sm" | "md" | "lg" | "xl";
|
|
6
|
+
|
|
7
|
+
/** Props for the Spinner component. */
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Size of the spinner. Defaults to "md". */
|
|
10
|
+
size?: SpinnerSize;
|
|
11
|
+
/** Additional CSS class name. */
|
|
12
|
+
className?: string;
|
|
13
|
+
/** Accessible label for screen readers. Defaults to "Loading". */
|
|
14
|
+
label?: string;
|
|
15
|
+
/** Whether this spinner is the primary live region. Defaults to false to avoid multiple live regions. */
|
|
16
|
+
liveRegion?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Inline spinning loader that inherits the current text color.
|
|
21
|
+
* Use alongside disabled buttons or hint text to signal in-flight async operations.
|
|
22
|
+
*/
|
|
23
|
+
export function Spinner({ size = "md", className, label = "Loading", liveRegion = false }: Props): JSX.Element {
|
|
24
|
+
return (
|
|
25
|
+
<span
|
|
26
|
+
className={`${styles.spinner} ${styles[size]} ${className ?? ""}`}
|
|
27
|
+
role={liveRegion ? "status" : undefined}
|
|
28
|
+
aria-label={label}
|
|
29
|
+
aria-hidden={liveRegion ? undefined : true}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// SplashScreen — full-viewport loading state shown before initial data arrives
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
.splash {
|
|
6
|
+
min-height: 100vh;
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
gap: var(--space-lg);
|
|
12
|
+
background: var(--bg-base);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.logo {
|
|
16
|
+
display: block;
|
|
17
|
+
width: 128px;
|
|
18
|
+
height: 128px;
|
|
19
|
+
object-fit: contain;
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { expect } from "@storybook/test";
|
|
3
|
+
import { SplashScreen } from "./SplashScreen.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof SplashScreen> = {
|
|
6
|
+
component: SplashScreen,
|
|
7
|
+
title: "Primitives/Display/SplashScreen",
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
};
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
/** Default splash screen with logo and spinner. */
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
play: async ({ canvas }) => {
|
|
16
|
+
const splash = canvas.getByTestId("splash-screen");
|
|
17
|
+
await expect(splash).toBeInTheDocument();
|
|
18
|
+
// Should contain the logo image
|
|
19
|
+
const logo = canvas.getByAltText("Grackle");
|
|
20
|
+
await expect(logo).toBeInTheDocument();
|
|
21
|
+
// Should contain a spinner with live region
|
|
22
|
+
const spinner = canvas.getByRole("status");
|
|
23
|
+
await expect(spinner).toBeInTheDocument();
|
|
24
|
+
await expect(spinner).toHaveAttribute("aria-label", "Loading Grackle");
|
|
25
|
+
},
|
|
26
|
+
};
|