@carto/ps-react-ui 4.9.1 → 4.11.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 (188) hide show
  1. package/dist/category-Dnd2_j0x.js +719 -0
  2. package/dist/category-Dnd2_j0x.js.map +1 -0
  3. package/dist/change-column-BiuuHCDN.js +1156 -0
  4. package/dist/change-column-BiuuHCDN.js.map +1 -0
  5. package/dist/chat.js +1507 -0
  6. package/dist/chat.js.map +1 -0
  7. package/dist/components.js +122 -120
  8. package/dist/components.js.map +1 -1
  9. package/dist/copy-button-DGL1tyli.js +26 -0
  10. package/dist/copy-button-DGL1tyli.js.map +1 -0
  11. package/dist/{data-zoom-layout-0QSptXG_.js → data-zoom-layout--YiY6ko_.js} +4 -3
  12. package/dist/{data-zoom-layout-0QSptXG_.js.map → data-zoom-layout--YiY6ko_.js.map} +1 -1
  13. package/dist/{download-config-CzmjOT2T.js → download-config-oJIFZ2WC.js} +9 -8
  14. package/dist/{download-config-CzmjOT2T.js.map → download-config-oJIFZ2WC.js.map} +1 -1
  15. package/dist/{spread-Y9R1f5dm.js → spread-CPis22AE.js} +4 -3
  16. package/dist/{spread-Y9R1f5dm.js.map → spread-CPis22AE.js.map} +1 -1
  17. package/dist/types/chat/bubbles/chat-error-message.d.ts +2 -0
  18. package/dist/types/chat/bubbles/chat-suggestion-button.d.ts +2 -0
  19. package/dist/types/chat/bubbles/chat-user-message.d.ts +2 -0
  20. package/dist/types/chat/bubbles/index.d.ts +4 -0
  21. package/dist/types/chat/const.d.ts +4 -0
  22. package/dist/types/chat/containers/chat-content.d.ts +2 -0
  23. package/dist/types/chat/containers/chat-footer.d.ts +2 -0
  24. package/dist/types/chat/containers/chat-header.d.ts +2 -0
  25. package/dist/types/chat/containers/chat-starter.d.ts +2 -0
  26. package/dist/types/chat/containers/index.d.ts +4 -0
  27. package/dist/types/chat/containers/styles.d.ts +93 -0
  28. package/dist/types/chat/feedback/chat-loader.d.ts +2 -0
  29. package/dist/types/chat/feedback/chat-rating-action.d.ts +2 -0
  30. package/dist/types/chat/feedback/chat-thinking.d.ts +2 -0
  31. package/dist/types/chat/feedback/chat-tool-code-area.d.ts +2 -0
  32. package/dist/types/chat/feedback/chat-tool-full-view-dialog.d.ts +2 -0
  33. package/dist/types/chat/feedback/chat-tool-group.d.ts +2 -0
  34. package/dist/types/chat/feedback/chat-tool-trace.d.ts +3 -0
  35. package/dist/types/chat/feedback/get-tool-label.d.ts +2 -0
  36. package/dist/types/chat/feedback/index.d.ts +8 -0
  37. package/dist/types/chat/feedback/styles.d.ts +211 -0
  38. package/dist/types/chat/index.d.ts +20 -0
  39. package/dist/types/chat/types.d.ts +184 -0
  40. package/dist/types/chat/use-typewriter.d.ts +30 -0
  41. package/dist/types/components/copy-button/copy-button.d.ts +2 -0
  42. package/dist/types/components/copy-button/types.d.ts +6 -0
  43. package/dist/types/components/index.d.ts +2 -0
  44. package/dist/types/widgets/actions/brush-toggle/style.d.ts +1 -1
  45. package/dist/types/widgets/actions/shared/styles.d.ts +1 -1
  46. package/dist/types/widgets/actions/zoom-toggle/style.d.ts +1 -1
  47. package/dist/types/widgets/echart/types.d.ts +1 -1
  48. package/dist/types/widgets/toolbar-actions/styles.d.ts +1 -1
  49. package/dist/types/widgets-v2/actions/brush-toggle/style.d.ts +1 -1
  50. package/dist/types/widgets-v2/actions/change-column/style.d.ts +1 -1
  51. package/dist/types/widgets-v2/actions/fullscreen/style.d.ts +1 -1
  52. package/dist/types/widgets-v2/actions/index.d.ts +1 -0
  53. package/dist/types/widgets-v2/actions/lock-selection/style.d.ts +1 -1
  54. package/dist/types/widgets-v2/actions/relative-data/style.d.ts +1 -1
  55. package/dist/types/widgets-v2/actions/searcher/style.d.ts +1 -1
  56. package/dist/types/widgets-v2/actions/show-all/index.d.ts +2 -0
  57. package/dist/types/widgets-v2/actions/show-all/labels.d.ts +5 -0
  58. package/dist/types/widgets-v2/actions/show-all/show-all.d.ts +33 -0
  59. package/dist/types/widgets-v2/actions/show-all/style.d.ts +8 -0
  60. package/dist/types/widgets-v2/actions/stack-toggle/style.d.ts +1 -1
  61. package/dist/types/widgets-v2/actions/zoom-toggle/style.d.ts +1 -1
  62. package/dist/types/widgets-v2/category/category-ui.d.ts +9 -2
  63. package/dist/types/widgets-v2/category/category.d.ts +9 -2
  64. package/dist/types/widgets-v2/category/components/category-row-other.d.ts +19 -6
  65. package/dist/types/widgets-v2/category/style.d.ts +21 -2
  66. package/dist/types/widgets-v2/category/types.d.ts +2 -0
  67. package/dist/types/widgets-v2/index.d.ts +3 -2
  68. package/dist/types/widgets-v2/selection-summary/labels.d.ts +7 -2
  69. package/dist/types/widgets-v2/selection-summary/selection-summary.d.ts +13 -6
  70. package/dist/types/widgets-v2/selection-summary/style.d.ts +15 -0
  71. package/dist/widgets/actions.js +115 -114
  72. package/dist/widgets/actions.js.map +1 -1
  73. package/dist/widgets/bar.js +1 -1
  74. package/dist/widgets/category.js +9 -8
  75. package/dist/widgets/category.js.map +1 -1
  76. package/dist/widgets/formula.js +11 -10
  77. package/dist/widgets/formula.js.map +1 -1
  78. package/dist/widgets/histogram.js +7 -6
  79. package/dist/widgets/histogram.js.map +1 -1
  80. package/dist/widgets/markdown.js +9 -8
  81. package/dist/widgets/markdown.js.map +1 -1
  82. package/dist/widgets/pie.js +1 -1
  83. package/dist/widgets/scatterplot.js +1 -1
  84. package/dist/widgets/spread.js +9 -8
  85. package/dist/widgets/spread.js.map +1 -1
  86. package/dist/widgets/table.js +17 -16
  87. package/dist/widgets/table.js.map +1 -1
  88. package/dist/widgets/timeseries.js +1 -1
  89. package/dist/widgets/utils.js +1 -1
  90. package/dist/widgets/wrapper.js +3 -2
  91. package/dist/widgets/wrapper.js.map +1 -1
  92. package/dist/widgets-v2/actions.js +41 -37
  93. package/dist/widgets-v2/bar.js +8 -7
  94. package/dist/widgets-v2/bar.js.map +1 -1
  95. package/dist/widgets-v2/category.js +22 -21
  96. package/dist/widgets-v2/category.js.map +1 -1
  97. package/dist/widgets-v2/formula.js +23 -22
  98. package/dist/widgets-v2/formula.js.map +1 -1
  99. package/dist/widgets-v2/histogram.js +10 -9
  100. package/dist/widgets-v2/histogram.js.map +1 -1
  101. package/dist/widgets-v2/markdown.js +9 -8
  102. package/dist/widgets-v2/markdown.js.map +1 -1
  103. package/dist/widgets-v2/pie.js +7 -6
  104. package/dist/widgets-v2/pie.js.map +1 -1
  105. package/dist/widgets-v2/scatterplot.js +9 -8
  106. package/dist/widgets-v2/scatterplot.js.map +1 -1
  107. package/dist/widgets-v2/spread.js +9 -8
  108. package/dist/widgets-v2/spread.js.map +1 -1
  109. package/dist/widgets-v2/table.js +16 -15
  110. package/dist/widgets-v2/table.js.map +1 -1
  111. package/dist/widgets-v2/timeseries.js +8 -7
  112. package/dist/widgets-v2/timeseries.js.map +1 -1
  113. package/dist/widgets-v2/utils.js +1 -1
  114. package/dist/widgets-v2.js +276 -271
  115. package/dist/widgets-v2.js.map +1 -1
  116. package/package.json +7 -3
  117. package/src/chat/bubbles/chat-agent-message.test.tsx +30 -0
  118. package/src/chat/bubbles/chat-agent-message.tsx +11 -0
  119. package/src/chat/bubbles/chat-error-message.test.tsx +40 -0
  120. package/src/chat/bubbles/chat-error-message.tsx +47 -0
  121. package/src/chat/bubbles/chat-suggestion-button.test.tsx +24 -0
  122. package/src/chat/bubbles/chat-suggestion-button.tsx +27 -0
  123. package/src/chat/bubbles/chat-user-message.test.tsx +27 -0
  124. package/src/chat/bubbles/chat-user-message.tsx +27 -0
  125. package/src/chat/bubbles/index.ts +4 -0
  126. package/src/chat/bubbles/styles.ts +148 -0
  127. package/src/chat/const.ts +4 -0
  128. package/src/chat/containers/chat-content.test.tsx +269 -0
  129. package/src/chat/containers/chat-content.tsx +142 -0
  130. package/src/chat/containers/chat-footer.test.tsx +34 -0
  131. package/src/chat/containers/chat-footer.tsx +78 -0
  132. package/src/chat/containers/chat-header.test.tsx +28 -0
  133. package/src/chat/containers/chat-header.tsx +29 -0
  134. package/src/chat/containers/chat-starter.test.tsx +32 -0
  135. package/src/chat/containers/chat-starter.tsx +75 -0
  136. package/src/chat/containers/index.ts +4 -0
  137. package/src/chat/containers/styles.ts +96 -0
  138. package/src/chat/feedback/chat-actions-container.test.tsx +64 -0
  139. package/src/chat/feedback/chat-actions-container.tsx +7 -0
  140. package/src/chat/feedback/chat-loader.test.tsx +10 -0
  141. package/src/chat/feedback/chat-loader.tsx +31 -0
  142. package/src/chat/feedback/chat-rating-action.tsx +43 -0
  143. package/src/chat/feedback/chat-thinking.test.tsx +15 -0
  144. package/src/chat/feedback/chat-thinking.tsx +23 -0
  145. package/src/chat/feedback/chat-tool-code-area.test.tsx +23 -0
  146. package/src/chat/feedback/chat-tool-code-area.tsx +71 -0
  147. package/src/chat/feedback/chat-tool-full-view-dialog.test.tsx +39 -0
  148. package/src/chat/feedback/chat-tool-full-view-dialog.tsx +121 -0
  149. package/src/chat/feedback/chat-tool-group.test.tsx +84 -0
  150. package/src/chat/feedback/chat-tool-group.tsx +156 -0
  151. package/src/chat/feedback/chat-tool-trace.test.tsx +81 -0
  152. package/src/chat/feedback/chat-tool-trace.tsx +192 -0
  153. package/src/chat/feedback/get-tool-label.test.tsx +91 -0
  154. package/src/chat/feedback/get-tool-label.ts +13 -0
  155. package/src/chat/feedback/index.ts +8 -0
  156. package/src/chat/feedback/styles.ts +229 -0
  157. package/src/chat/index.ts +59 -0
  158. package/src/chat/types.ts +215 -0
  159. package/src/chat/use-typewriter.test.tsx +38 -0
  160. package/src/chat/use-typewriter.ts +82 -0
  161. package/src/components/copy-button/copy-button.test.tsx +41 -0
  162. package/src/components/copy-button/copy-button.tsx +31 -0
  163. package/src/components/copy-button/types.ts +10 -0
  164. package/src/components/index.ts +3 -0
  165. package/src/widgets/echart/types.ts +1 -1
  166. package/src/widgets-v2/actions/index.ts +8 -0
  167. package/src/widgets-v2/actions/show-all/index.ts +7 -0
  168. package/src/widgets-v2/actions/show-all/labels.ts +8 -0
  169. package/src/widgets-v2/actions/show-all/show-all.test.tsx +50 -0
  170. package/src/widgets-v2/actions/show-all/show-all.tsx +72 -0
  171. package/src/widgets-v2/actions/show-all/style.ts +8 -0
  172. package/src/widgets-v2/category/category-ui.test.tsx +26 -10
  173. package/src/widgets-v2/category/category-ui.tsx +13 -3
  174. package/src/widgets-v2/category/category.test.tsx +4 -4
  175. package/src/widgets-v2/category/category.tsx +10 -1
  176. package/src/widgets-v2/category/components/category-row-other.test.tsx +36 -7
  177. package/src/widgets-v2/category/components/category-row-other.tsx +64 -13
  178. package/src/widgets-v2/category/style.ts +35 -4
  179. package/src/widgets-v2/category/types.ts +2 -0
  180. package/src/widgets-v2/index.ts +3 -0
  181. package/src/widgets-v2/selection-summary/labels.ts +8 -4
  182. package/src/widgets-v2/selection-summary/selection-summary.test.tsx +15 -9
  183. package/src/widgets-v2/selection-summary/selection-summary.tsx +42 -22
  184. package/src/widgets-v2/selection-summary/style.ts +15 -0
  185. package/dist/category-DwaeYjpX.js +0 -656
  186. package/dist/category-DwaeYjpX.js.map +0 -1
  187. package/dist/change-column-B4IT0rh6.js +0 -1110
  188. package/dist/change-column-B4IT0rh6.js.map +0 -1
@@ -0,0 +1,142 @@
1
+ import {
2
+ forwardRef,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useRef,
6
+ useState,
7
+ } from 'react'
8
+ import { Box, Fab } from '@mui/material'
9
+ import { KeyboardArrowDown } from '@mui/icons-material'
10
+ import type { ChatContentProps, ChatContentRef } from '../types'
11
+ import { styles } from './styles'
12
+
13
+ /** Slack in px for treating the user as "at the bottom" when content grows. */
14
+ const AUTO_SCROLL_THRESHOLD = 32
15
+
16
+ export const ChatContent = forwardRef<ChatContentRef, ChatContentProps>(
17
+ function ChatContent({ children, autoScroll = true, labels = {}, sx }, ref) {
18
+ const scrollRef = useRef<HTMLDivElement>(null)
19
+ const topSentinelRef = useRef<HTMLDivElement>(null)
20
+ const bottomSentinelRef = useRef<HTMLDivElement>(null)
21
+
22
+ const [isAtTop, setIsAtTop] = useState(true)
23
+ const [isAtBottom, setIsAtBottom] = useState(true)
24
+
25
+ const scrollToBottom = () => {
26
+ scrollRef.current?.scrollTo({
27
+ top: scrollRef.current.scrollHeight,
28
+ behavior: 'smooth',
29
+ })
30
+ }
31
+
32
+ const scrollToTop = () => {
33
+ scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
34
+ }
35
+
36
+ useImperativeHandle(
37
+ ref,
38
+ () => ({ scrollToBottom, scrollToTop, isAtBottom, isAtTop }),
39
+ [isAtBottom, isAtTop],
40
+ )
41
+
42
+ useEffect(() => {
43
+ const root = scrollRef.current
44
+ const topEl = topSentinelRef.current
45
+ const bottomEl = bottomSentinelRef.current
46
+ if (!root || !topEl || !bottomEl) return
47
+
48
+ const topObserver = new IntersectionObserver(
49
+ ([entry]) => setIsAtTop(entry?.isIntersecting ?? true),
50
+ { root },
51
+ )
52
+ const bottomObserver = new IntersectionObserver(
53
+ ([entry]) => setIsAtBottom(entry?.isIntersecting ?? true),
54
+ { root },
55
+ )
56
+ topObserver.observe(topEl)
57
+ bottomObserver.observe(bottomEl)
58
+ return () => {
59
+ topObserver.disconnect()
60
+ bottomObserver.disconnect()
61
+ }
62
+ }, [])
63
+
64
+ useEffect(() => {
65
+ if (!autoScroll) return
66
+ const root = scrollRef.current
67
+ if (!root) return
68
+
69
+ // We can't watch `children` to detect new content: tool traces and
70
+ // `useTypewriter` live inside child components and update via local
71
+ // state, so they mutate the DOM without re-rendering `ChatContent`.
72
+ // MutationObserver picks up every DOM change regardless of source.
73
+ let prevScrollHeight = root.scrollHeight
74
+ let rafId: number | null = null
75
+
76
+ const check = () => {
77
+ rafId = null
78
+ const newScrollHeight = root.scrollHeight
79
+ if (newScrollHeight > prevScrollHeight) {
80
+ const growth = newScrollHeight - prevScrollHeight
81
+ // If the distance from the bottom doesn't exceed the amount the
82
+ // content just grew (plus a small slack), the user was at the
83
+ // bottom *before* the growth — scroll them to the new bottom.
84
+ // Otherwise they've scrolled up to read history and we leave them
85
+ // alone.
86
+ const distanceFromBottom =
87
+ newScrollHeight - root.scrollTop - root.clientHeight
88
+ if (distanceFromBottom <= growth + AUTO_SCROLL_THRESHOLD) {
89
+ root.scrollTo({ top: newScrollHeight, behavior: 'smooth' })
90
+ }
91
+ }
92
+ prevScrollHeight = newScrollHeight
93
+ }
94
+
95
+ const mo = new MutationObserver(() => {
96
+ rafId ??= requestAnimationFrame(check)
97
+ })
98
+ mo.observe(root, {
99
+ childList: true,
100
+ subtree: true,
101
+ characterData: true,
102
+ })
103
+ return () => {
104
+ mo.disconnect()
105
+ if (rafId !== null) cancelAnimationFrame(rafId)
106
+ }
107
+ }, [autoScroll])
108
+
109
+ return (
110
+ <Box
111
+ ref={scrollRef}
112
+ sx={{
113
+ ...styles.content,
114
+ borderTopColor: ({ palette }) =>
115
+ isAtTop ? 'transparent' : palette.divider,
116
+ borderBottomColor: ({ palette }) =>
117
+ isAtBottom ? 'transparent' : palette.divider,
118
+ ...sx,
119
+ }}
120
+ >
121
+ <Box ref={topSentinelRef} sx={styles.sentinel} aria-hidden />
122
+ {children}
123
+ <Box ref={bottomSentinelRef} sx={styles.sentinel} aria-hidden />
124
+ <Box sx={styles.jumpToLatestWrapper}>
125
+ <Fab
126
+ size='small'
127
+ onClick={scrollToBottom}
128
+ aria-label={labels.jumpToLatest ?? 'Jump to latest'}
129
+ sx={{
130
+ ...styles.jumpToLatest,
131
+ opacity: isAtBottom ? 0 : 1,
132
+ pointerEvents: isAtBottom ? 'none' : 'auto',
133
+ }}
134
+ >
135
+ <KeyboardArrowDown />
136
+ </Fab>
137
+ </Box>
138
+ </Box>
139
+ )
140
+ },
141
+ )
142
+ ChatContent.displayName = 'ChatContent'
@@ -0,0 +1,34 @@
1
+ import { describe, test, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { ChatFooter } from './chat-footer'
4
+
5
+ describe('ChatFooter', () => {
6
+ const defaultProps = {
7
+ value: '',
8
+ onChange: vi.fn(),
9
+ onSend: vi.fn(),
10
+ }
11
+
12
+ test('renders send button', () => {
13
+ render(<ChatFooter {...defaultProps} />)
14
+ expect(screen.getByLabelText('Send')).toBeTruthy()
15
+ })
16
+
17
+ test('send button is disabled when value is empty', () => {
18
+ render(<ChatFooter {...defaultProps} />)
19
+ expect(screen.getByLabelText('Send').hasAttribute('disabled')).toBeTruthy()
20
+ })
21
+
22
+ test('renders stop button when generating', () => {
23
+ render(<ChatFooter {...defaultProps} isGenerating onStop={vi.fn()} />)
24
+ expect(screen.getByLabelText('Stop')).toBeTruthy()
25
+ })
26
+
27
+ test('calls onChange when typing', () => {
28
+ const onChange = vi.fn()
29
+ render(<ChatFooter {...defaultProps} onChange={onChange} />)
30
+ const input = screen.getByPlaceholderText('Type a message...')
31
+ fireEvent.change(input, { target: { value: 'hello' } })
32
+ expect(onChange).toHaveBeenCalledWith('hello')
33
+ })
34
+ })
@@ -0,0 +1,78 @@
1
+ import {
2
+ Box,
3
+ FilledInput,
4
+ FormControl,
5
+ FormHelperText,
6
+ IconButton,
7
+ } from '@mui/material'
8
+ import { ArrowUpwardOutlined, StopCircleOutlined } from '@mui/icons-material'
9
+ import type { ChatFooterProps } from '../types'
10
+ import { styles } from './styles'
11
+
12
+ const DEFAULT_CAPTION =
13
+ 'Responses are AI generated. Please verify key information.'
14
+
15
+ export function ChatFooter({
16
+ value,
17
+ onChange,
18
+ onSend,
19
+ onStop,
20
+ isGenerating = false,
21
+ disabled = false,
22
+ placeholder = 'Type a message...',
23
+ labels = {},
24
+ caption = DEFAULT_CAPTION,
25
+ sx,
26
+ }: ChatFooterProps) {
27
+ const canSend = value.trim() && !disabled && !isGenerating
28
+ const handleKeyDown = (e: React.KeyboardEvent) => {
29
+ if (e.key === 'Enter' && !e.shiftKey) {
30
+ e.preventDefault()
31
+ if (canSend) {
32
+ onSend()
33
+ }
34
+ }
35
+ }
36
+
37
+ return (
38
+ <FormControl fullWidth sx={{ ...styles.footerWrapper, ...sx }}>
39
+ <FilledInput
40
+ multiline
41
+ value={value}
42
+ onChange={(e) => onChange(e.target.value)}
43
+ onKeyDown={handleKeyDown}
44
+ placeholder={placeholder}
45
+ disabled={disabled || isGenerating}
46
+ sx={styles.footer}
47
+ style={{ minHeight: 0 }}
48
+ fullWidth
49
+ size='small'
50
+ />
51
+ <Box sx={styles.footerCorner}>
52
+ {isGenerating && onStop ? (
53
+ <IconButton
54
+ size='small'
55
+ onClick={onStop}
56
+ disabled={disabled}
57
+ aria-label={labels.stop ?? 'Stop'}
58
+ >
59
+ <StopCircleOutlined />
60
+ </IconButton>
61
+ ) : (
62
+ <IconButton
63
+ size='small'
64
+ variant='contained'
65
+ onClick={onSend}
66
+ disabled={!canSend}
67
+ aria-label={labels.send ?? 'Send'}
68
+ >
69
+ <ArrowUpwardOutlined />
70
+ </IconButton>
71
+ )}
72
+ </Box>
73
+ {caption ? (
74
+ <FormHelperText sx={styles.footerCaption}>{caption}</FormHelperText>
75
+ ) : null}
76
+ </FormControl>
77
+ )
78
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, test, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { ChatHeader } from './chat-header'
4
+
5
+ describe('ChatHeader', () => {
6
+ test('renders title', () => {
7
+ render(<ChatHeader title='Chat Assistant' />)
8
+ expect(screen.getByText('Chat Assistant')).toBeTruthy()
9
+ })
10
+
11
+ test('renders close button when onClose provided', () => {
12
+ const onClose = vi.fn()
13
+ render(<ChatHeader title='Test' onClose={onClose} />)
14
+ const closeButton = screen.getByLabelText('Close')
15
+ fireEvent.click(closeButton)
16
+ expect(onClose).toHaveBeenCalledTimes(1)
17
+ })
18
+
19
+ test('renders rightSlot', () => {
20
+ render(<ChatHeader title='Test' rightSlot={<button>Custom</button>} />)
21
+ expect(screen.getByText('Custom')).toBeTruthy()
22
+ })
23
+
24
+ test('renders leftSlot', () => {
25
+ render(<ChatHeader title='Test' leftSlot={<button>Back</button>} />)
26
+ expect(screen.getByText('Back')).toBeTruthy()
27
+ })
28
+ })
@@ -0,0 +1,29 @@
1
+ import { Box, IconButton, Typography } from '@mui/material'
2
+ import { Close } from '@mui/icons-material'
3
+ import type { ChatHeaderProps } from '../types'
4
+ import { styles } from './styles'
5
+
6
+ export function ChatHeader({
7
+ leftSlot,
8
+ title,
9
+ rightSlot,
10
+ onClose,
11
+ sx,
12
+ }: ChatHeaderProps) {
13
+ return (
14
+ <Box sx={{ ...styles.header, ...sx }}>
15
+ {leftSlot}
16
+ <Typography variant='subtitle2' sx={styles.headerTitle}>
17
+ {title}
18
+ </Typography>
19
+ <Box sx={styles.headerActions}>
20
+ {rightSlot}
21
+ {onClose && (
22
+ <IconButton size='medium' onClick={onClose} aria-label='Close'>
23
+ <Close />
24
+ </IconButton>
25
+ )}
26
+ </Box>
27
+ </Box>
28
+ )
29
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, test, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { ChatStarter } from './chat-starter'
4
+
5
+ describe('ChatStarter', () => {
6
+ const defaultItems = ['Suggestion 1', 'Suggestion 2']
7
+
8
+ test('renders title and description', () => {
9
+ render(
10
+ <ChatStarter
11
+ title='Welcome'
12
+ description='How can I help?'
13
+ items={defaultItems}
14
+ />,
15
+ )
16
+ expect(screen.getByText('Welcome')).toBeTruthy()
17
+ expect(screen.getByText('How can I help?')).toBeTruthy()
18
+ })
19
+
20
+ test('renders suggestion items', () => {
21
+ render(<ChatStarter items={defaultItems} />)
22
+ expect(screen.getByText('Suggestion 1')).toBeTruthy()
23
+ expect(screen.getByText('Suggestion 2')).toBeTruthy()
24
+ })
25
+
26
+ test('calls item onClick when clicked', () => {
27
+ const onSelect = vi.fn()
28
+ render(<ChatStarter items={defaultItems} onSelect={onSelect} />)
29
+ fireEvent.click(screen.getByText('Suggestion 1'))
30
+ expect(onSelect).toHaveBeenCalledTimes(1)
31
+ })
32
+ })
@@ -0,0 +1,75 @@
1
+ import { Box, Typography } from '@mui/material'
2
+ import type { ChatStarterProps } from '../types'
3
+ import { ChatSuggestionButton } from '../bubbles/chat-suggestion-button'
4
+ import { styles } from './styles'
5
+
6
+ const DEFAULT_PALETTE = [
7
+ '#C9DB7440', // pastel green
8
+ '#FE88B140', // pastel red
9
+ '#9EB9F340', // pastel blue
10
+ '#F6CF7140', // pastel yellow
11
+ ]
12
+
13
+ export function ChatStarter({
14
+ icon,
15
+ title,
16
+ description,
17
+ items,
18
+ size = 'small',
19
+ onSelect,
20
+ sx,
21
+ }: ChatStarterProps) {
22
+ const fullItems = items.map((item, index) => {
23
+ if (typeof item === 'string') {
24
+ return {
25
+ label: item,
26
+ color: DEFAULT_PALETTE[index],
27
+ }
28
+ } else {
29
+ return {
30
+ label: item.label,
31
+ color: item.color ?? DEFAULT_PALETTE[index],
32
+ }
33
+ }
34
+ })
35
+ return (
36
+ <Box
37
+ sx={{
38
+ ...styles.starter,
39
+ gap: size === 'small' ? 1 : 2,
40
+ ...sx,
41
+ }}
42
+ >
43
+ {icon}
44
+ {title && (
45
+ <Typography variant={size === 'small' ? 'h6' : 'h5'}>
46
+ {title}
47
+ </Typography>
48
+ )}
49
+ {description && (
50
+ <Typography
51
+ variant={size === 'small' ? 'body2' : 'body1'}
52
+ color='text.secondary'
53
+ >
54
+ {description}
55
+ </Typography>
56
+ )}
57
+ <Box
58
+ sx={{
59
+ ...styles.starterItems,
60
+ gap: size === 'small' ? 1 : 2,
61
+ ...(items.length > 2 ? styles.starterItemsTwoCol : undefined),
62
+ }}
63
+ >
64
+ {fullItems.map((item, i) => (
65
+ <ChatSuggestionButton
66
+ key={i}
67
+ label={item.label}
68
+ onClick={() => onSelect?.(item.label)}
69
+ color={item.color}
70
+ />
71
+ ))}
72
+ </Box>
73
+ </Box>
74
+ )
75
+ }
@@ -0,0 +1,4 @@
1
+ export { ChatContent } from './chat-content'
2
+ export { ChatHeader } from './chat-header'
3
+ export { ChatFooter } from './chat-footer'
4
+ export { ChatStarter } from './chat-starter'
@@ -0,0 +1,96 @@
1
+ import type { SxProps, Theme } from '@mui/material'
2
+ import { CHAT_MAX_WIDTH } from '../const'
3
+
4
+ export const styles = {
5
+ header: {
6
+ display: 'flex',
7
+ alignItems: 'center',
8
+ justifyContent: 'space-between',
9
+ padding: ({ spacing }) => spacing(1),
10
+ },
11
+ headerTitle: {
12
+ flexGrow: 1,
13
+ px: ({ spacing }) => spacing(0.5),
14
+ },
15
+ headerActions: {
16
+ display: 'flex',
17
+ alignItems: 'center',
18
+ marginLeft: ({ spacing }) => spacing(2),
19
+ gap: ({ spacing }) => spacing(0.5),
20
+ },
21
+ footerWrapper: {
22
+ padding: ({ spacing }) => spacing(1),
23
+ position: 'relative',
24
+ },
25
+ footerCorner: {
26
+ position: 'absolute',
27
+ bottom: '38px',
28
+ right: `max(16px, calc(50% - ${CHAT_MAX_WIDTH / 2}px + 6px))`,
29
+ margin: '0 auto',
30
+ },
31
+ footer: {
32
+ maxWidth: CHAT_MAX_WIDTH,
33
+ margin: '0 auto',
34
+ '&.MuiFilledInput-root.MuiInputBase-multiline.MuiInputBase-sizeSmall textarea':
35
+ {
36
+ resize: 'none',
37
+ maxHeight: '10em',
38
+ overflowY: 'auto !important',
39
+ paddingRight: ({ spacing }) => spacing(5),
40
+ },
41
+ },
42
+ footerCaption: {
43
+ textAlign: 'center',
44
+ },
45
+ content: {
46
+ overflowY: 'auto',
47
+ flex: 1,
48
+ position: 'relative',
49
+ mx: 'auto',
50
+ maxWidth: CHAT_MAX_WIDTH,
51
+ width: '100%',
52
+ maxHeight: '100%',
53
+ pt: 1,
54
+ pb: 2,
55
+ px: 2,
56
+ display: 'flex',
57
+ flexDirection: 'column',
58
+ borderTopWidth: '1px',
59
+ borderTopStyle: 'solid',
60
+ borderBottomWidth: '1px',
61
+ borderBottomStyle: 'solid',
62
+ },
63
+ sentinel: {
64
+ height: '1px',
65
+ flexShrink: 0,
66
+ },
67
+ jumpToLatestWrapper: {
68
+ position: 'sticky',
69
+ bottom: ({ spacing }) => spacing(3),
70
+ height: 0,
71
+ display: 'flex',
72
+ justifyContent: 'center',
73
+ zIndex: 1,
74
+ },
75
+ jumpToLatest: {
76
+ transition: 'opacity 0.2s',
77
+ },
78
+ starter: {
79
+ display: 'flex',
80
+ flexDirection: 'column',
81
+ alignItems: 'center',
82
+ justifyContent: 'center',
83
+ padding: ({ spacing }) => spacing(3),
84
+ textAlign: 'center',
85
+ maxWidth: CHAT_MAX_WIDTH,
86
+ margin: '0 auto',
87
+ },
88
+ starterItems: {
89
+ display: 'grid',
90
+ width: '100%',
91
+ marginTop: ({ spacing }) => spacing(2),
92
+ },
93
+ starterItemsTwoCol: {
94
+ gridTemplateColumns: 'repeat(2, 1fr)',
95
+ },
96
+ } satisfies Record<string, SxProps<Theme>>
@@ -0,0 +1,64 @@
1
+ import { describe, test, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { ChatActionsContainer } from './chat-actions-container'
4
+ import { ChatRatingAction } from './chat-rating-action'
5
+
6
+ describe('ChatActionsContainer', () => {
7
+ test('renders children', () => {
8
+ render(
9
+ <ChatActionsContainer>
10
+ <span>child content</span>
11
+ </ChatActionsContainer>,
12
+ )
13
+ expect(screen.getByText('child content')).toBeTruthy()
14
+ })
15
+ })
16
+
17
+ describe('ChatRatingAction', () => {
18
+ test('renders thumb up and thumb down buttons', () => {
19
+ render(<ChatRatingAction />)
20
+ expect(screen.getByLabelText('Thumbs up')).toBeTruthy()
21
+ expect(screen.getByLabelText('Thumbs down')).toBeTruthy()
22
+ })
23
+
24
+ test('calls onRatingChange with up when thumbs up clicked', () => {
25
+ const onRatingChange = vi.fn()
26
+ render(<ChatRatingAction onRatingChange={onRatingChange} />)
27
+ fireEvent.click(screen.getByLabelText('Thumbs up'))
28
+ expect(onRatingChange).toHaveBeenCalledWith('up')
29
+ })
30
+
31
+ test('calls onRatingChange with null when active thumb up clicked', () => {
32
+ const onRatingChange = vi.fn()
33
+ render(<ChatRatingAction onRatingChange={onRatingChange} rating='up' />)
34
+ fireEvent.click(screen.getByLabelText('Thumbs up'))
35
+ expect(onRatingChange).toHaveBeenCalledWith(null)
36
+ })
37
+
38
+ test('calls onRatingChange with down when thumbs down clicked', () => {
39
+ const onRatingChange = vi.fn()
40
+ render(<ChatRatingAction onRatingChange={onRatingChange} />)
41
+ fireEvent.click(screen.getByLabelText('Thumbs down'))
42
+ expect(onRatingChange).toHaveBeenCalledWith('down')
43
+ })
44
+
45
+ test('highlights active rating up', () => {
46
+ const { container } = render(<ChatRatingAction rating='up' />)
47
+ expect(container.querySelector('[data-testid="ThumbUpIcon"]')).toBeTruthy()
48
+ })
49
+
50
+ test('highlights active rating down', () => {
51
+ const { container } = render(<ChatRatingAction rating='down' />)
52
+ expect(
53
+ container.querySelector('[data-testid="ThumbDownIcon"]'),
54
+ ).toBeTruthy()
55
+ })
56
+
57
+ test('renders with custom labels', () => {
58
+ render(
59
+ <ChatRatingAction labels={{ thumbUp: 'Like', thumbDown: 'Dislike' }} />,
60
+ )
61
+ expect(screen.getByLabelText('Like')).toBeTruthy()
62
+ expect(screen.getByLabelText('Dislike')).toBeTruthy()
63
+ })
64
+ })
@@ -0,0 +1,7 @@
1
+ import { Box, styled } from '@mui/material'
2
+
3
+ export const ChatActionsContainer = styled(Box)(({ theme }) => ({
4
+ display: 'flex',
5
+ alignItems: 'center',
6
+ gap: theme.spacing(0.5),
7
+ }))
@@ -0,0 +1,10 @@
1
+ import { describe, test, expect } from 'vitest'
2
+ import { render } from '@testing-library/react'
3
+ import { ChatLoader } from './chat-loader'
4
+
5
+ describe('ChatLoader', () => {
6
+ test('renders loading indicator', () => {
7
+ const { container } = render(<ChatLoader />)
8
+ expect(container.querySelector('[role="status"]')).toBeTruthy()
9
+ })
10
+ })
@@ -0,0 +1,31 @@
1
+ import { Box } from '@mui/material'
2
+ import type { ChatLoaderProps } from '../types'
3
+ import { styles } from './styles'
4
+
5
+ export function ChatLoader({ size = 24, labels = {}, sx }: ChatLoaderProps) {
6
+ return (
7
+ <Box
8
+ role='status'
9
+ aria-busy={true}
10
+ aria-label={labels.loading ?? 'Loading'}
11
+ sx={{ ...styles.loader, width: size, height: size, ...sx }}
12
+ >
13
+ <Box
14
+ component='span'
15
+ sx={{
16
+ ...styles.loaderOuterCircle,
17
+ width: size * 0.75,
18
+ height: size * 0.75,
19
+ }}
20
+ />
21
+ <Box
22
+ component='span'
23
+ sx={{
24
+ ...styles.loaderInnerCircle,
25
+ width: size * 0.32,
26
+ height: size * 0.32,
27
+ }}
28
+ />
29
+ </Box>
30
+ )
31
+ }
@@ -0,0 +1,43 @@
1
+ import { IconButton } from '@mui/material'
2
+ import {
3
+ ThumbUpOutlined,
4
+ ThumbDownOutlined,
5
+ ThumbUp,
6
+ ThumbDown,
7
+ } from '@mui/icons-material'
8
+ import type { ChatRatingActionProps } from '../types'
9
+
10
+ export function ChatRatingAction({
11
+ rating,
12
+ onRatingChange,
13
+ labels = {},
14
+ }: ChatRatingActionProps) {
15
+ return (
16
+ <>
17
+ <IconButton
18
+ size='small'
19
+ onClick={() => onRatingChange?.(rating === 'up' ? null : 'up')}
20
+ aria-label={labels.thumbUp ?? 'Thumbs up'}
21
+ color={rating === 'up' ? 'primary' : undefined}
22
+ >
23
+ {rating === 'up' ? (
24
+ <ThumbUp fontSize='small' />
25
+ ) : (
26
+ <ThumbUpOutlined fontSize='small' />
27
+ )}
28
+ </IconButton>
29
+ <IconButton
30
+ size='small'
31
+ onClick={() => onRatingChange?.(rating === 'down' ? null : 'down')}
32
+ aria-label={labels.thumbDown ?? 'Thumbs down'}
33
+ color={rating === 'down' ? 'primary' : undefined}
34
+ >
35
+ {rating === 'down' ? (
36
+ <ThumbDown fontSize='small' />
37
+ ) : (
38
+ <ThumbDownOutlined fontSize='small' />
39
+ )}
40
+ </IconButton>
41
+ </>
42
+ )
43
+ }