@carto/ps-react-ui 4.11.3 → 4.12.1
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/dist/chat.js +962 -733
- package/dist/chat.js.map +1 -1
- package/dist/csv-item-hH_Gt7ur.js +32 -0
- package/dist/csv-item-hH_Gt7ur.js.map +1 -0
- package/dist/{echart-BMPpj7n_.js → echart-Bdvbfx9s.js} +2 -2
- package/dist/echart-Bdvbfx9s.js.map +1 -0
- package/dist/{option-builders-F-c9ELi1.js → option-builders-DPeoyQaM.js} +41 -33
- package/dist/option-builders-DPeoyQaM.js.map +1 -0
- package/dist/png-item-9dNbB37T.js +57 -0
- package/dist/png-item-9dNbB37T.js.map +1 -0
- package/dist/table-B3ZWWhJt.js +383 -0
- package/dist/table-B3ZWWhJt.js.map +1 -0
- package/dist/types/chat/containers/chat-footer.d.ts +1 -1
- package/dist/types/chat/containers/styles.d.ts +79 -12
- package/dist/types/chat/index.d.ts +1 -1
- package/dist/types/chat/types.d.ts +21 -0
- package/dist/types/chat/use-typewriter.d.ts +5 -3
- package/dist/types/widgets/utils/chart-config/index.d.ts +1 -1
- package/dist/types/widgets-v2/actions/download/constants.d.ts +12 -0
- package/dist/types/widgets-v2/actions/download/csv-item.d.ts +38 -0
- package/dist/types/widgets-v2/actions/download/icons.d.ts +6 -0
- package/dist/types/widgets-v2/actions/download/index.d.ts +3 -1
- package/dist/types/widgets-v2/actions/index.d.ts +1 -1
- package/dist/types/widgets-v2/pie/skeleton.d.ts +9 -0
- package/dist/widgets/bar.js +1 -1
- package/dist/widgets/histogram.js +1 -1
- package/dist/widgets/pie.js +1 -1
- package/dist/widgets/scatterplot.js +5 -5
- package/dist/widgets/timeseries.js +1 -1
- package/dist/widgets/utils.js +1 -1
- package/dist/widgets-v2/actions.js +40 -36
- package/dist/widgets-v2/actions.js.map +1 -1
- package/dist/widgets-v2/bar.js +69 -76
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/category.js +50 -55
- package/dist/widgets-v2/category.js.map +1 -1
- package/dist/widgets-v2/echart.js +1 -1
- package/dist/widgets-v2/formula.js +37 -43
- package/dist/widgets-v2/formula.js.map +1 -1
- package/dist/widgets-v2/histogram.js +141 -147
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/dist/widgets-v2/markdown.js +18 -17
- package/dist/widgets-v2/markdown.js.map +1 -1
- package/dist/widgets-v2/pie.js +174 -126
- package/dist/widgets-v2/pie.js.map +1 -1
- package/dist/widgets-v2/scatterplot.js +156 -166
- package/dist/widgets-v2/scatterplot.js.map +1 -1
- package/dist/widgets-v2/spread.js +36 -41
- package/dist/widgets-v2/spread.js.map +1 -1
- package/dist/widgets-v2/table.js +46 -55
- package/dist/widgets-v2/table.js.map +1 -1
- package/dist/widgets-v2/timeseries.js +83 -89
- package/dist/widgets-v2/timeseries.js.map +1 -1
- package/dist/widgets-v2.js +3 -3
- package/package.json +1 -1
- package/src/chat/bubbles/styles.ts +5 -1
- package/src/chat/containers/chat-content.tsx +4 -1
- package/src/chat/containers/chat-footer.test.tsx +59 -0
- package/src/chat/containers/chat-footer.tsx +124 -36
- package/src/chat/containers/styles.ts +107 -16
- package/src/chat/feedback/styles.ts +11 -4
- package/src/chat/index.ts +1 -0
- package/src/chat/types.ts +22 -0
- package/src/chat/use-typewriter.ts +32 -24
- package/src/widgets/utils/chart-config/index.ts +1 -0
- package/src/widgets/utils/chart-config/option-builders.test.ts +34 -0
- package/src/widgets/utils/chart-config/option-builders.ts +21 -0
- package/src/widgets-v2/actions/download/constants.ts +14 -0
- package/src/widgets-v2/actions/download/csv-item.test.tsx +77 -0
- package/src/widgets-v2/actions/download/csv-item.tsx +71 -0
- package/src/widgets-v2/actions/download/icons.tsx +10 -1
- package/src/widgets-v2/actions/download/index.ts +3 -1
- package/src/widgets-v2/actions/download/png-item.tsx +2 -1
- package/src/widgets-v2/actions/index.ts +5 -0
- package/src/widgets-v2/bar/download.tsx +16 -22
- package/src/widgets-v2/bar/options.ts +3 -2
- package/src/widgets-v2/category/download.test.ts +9 -0
- package/src/widgets-v2/category/download.ts +16 -20
- package/src/widgets-v2/echart/edge-label-clamp.ts +7 -4
- package/src/widgets-v2/formula/download.tsx +23 -29
- package/src/widgets-v2/histogram/download.ts +22 -26
- package/src/widgets-v2/histogram/options.ts +3 -2
- package/src/widgets-v2/markdown/{download.ts → download.tsx} +5 -2
- package/src/widgets-v2/pie/download.ts +16 -20
- package/src/widgets-v2/pie/skeleton.test.tsx +6 -3
- package/src/widgets-v2/pie/skeleton.tsx +69 -7
- package/src/widgets-v2/scatterplot/download.ts +16 -20
- package/src/widgets-v2/scatterplot/options.ts +3 -6
- package/src/widgets-v2/spread/download.ts +23 -27
- package/src/widgets-v2/table/download.test.ts +10 -0
- package/src/widgets-v2/table/download.ts +11 -15
- package/src/widgets-v2/table/helpers.test.ts +19 -0
- package/src/widgets-v2/table/helpers.ts +7 -12
- package/src/widgets-v2/timeseries/download.ts +36 -40
- package/src/widgets-v2/timeseries/options.ts +3 -2
- package/dist/echart-BMPpj7n_.js.map +0 -1
- package/dist/option-builders-F-c9ELi1.js.map +0 -1
- package/dist/png-item-BE9uEqlD.js +0 -45
- package/dist/png-item-BE9uEqlD.js.map +0 -1
- package/dist/table-C9IMbTr0.js +0 -385
- package/dist/table-C9IMbTr0.js.map +0 -1
- package/dist/types/chat/feedback/styles.d.ts +0 -211
- package/dist/types/widgets/utils/chart-config/option-builders.d.ts +0 -124
|
@@ -1,16 +1,25 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
1
2
|
import {
|
|
2
3
|
Box,
|
|
3
|
-
|
|
4
|
+
Button,
|
|
4
5
|
FormControl,
|
|
5
6
|
FormHelperText,
|
|
6
7
|
IconButton,
|
|
8
|
+
InputBase,
|
|
9
|
+
Menu,
|
|
10
|
+
MenuItem,
|
|
11
|
+
Typography,
|
|
7
12
|
} from '@mui/material'
|
|
8
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ArrowUpwardOutlined,
|
|
15
|
+
StopCircleOutlined,
|
|
16
|
+
KeyboardArrowDown,
|
|
17
|
+
} from '@mui/icons-material'
|
|
9
18
|
import type { ChatFooterProps } from '../types'
|
|
10
19
|
import { styles } from './styles'
|
|
11
20
|
|
|
12
21
|
const DEFAULT_CAPTION =
|
|
13
|
-
'Responses are AI
|
|
22
|
+
'Responses are AI-generated. Please verify key information.'
|
|
14
23
|
|
|
15
24
|
export function ChatFooter({
|
|
16
25
|
value,
|
|
@@ -22,9 +31,29 @@ export function ChatFooter({
|
|
|
22
31
|
placeholder = 'Type a message...',
|
|
23
32
|
labels = {},
|
|
24
33
|
caption = DEFAULT_CAPTION,
|
|
34
|
+
models,
|
|
35
|
+
selectedModel,
|
|
36
|
+
onModelChange,
|
|
37
|
+
startToolbarSlot,
|
|
38
|
+
endToolbarSlot,
|
|
25
39
|
sx,
|
|
26
40
|
}: ChatFooterProps) {
|
|
41
|
+
const [modelAnchor, setModelAnchor] = useState<HTMLElement | null>(null)
|
|
27
42
|
const canSend = value.trim() && !disabled && !isGenerating
|
|
43
|
+
// Only worth showing when there's an actual choice — a single option is dead UI.
|
|
44
|
+
const showModelSelector = (models?.length ?? 0) > 1
|
|
45
|
+
const modelLabel = labels.model ?? 'Select model'
|
|
46
|
+
// Visible trigger text: the matched option's label, the raw value as a
|
|
47
|
+
// fallback, or the placeholder when nothing is selected yet.
|
|
48
|
+
const selectedLabel =
|
|
49
|
+
models?.find((m) => m.value === selectedModel)?.label ??
|
|
50
|
+
(selectedModel && selectedModel.length > 0 ? selectedModel : modelLabel)
|
|
51
|
+
|
|
52
|
+
// The slots row (start slot + model selector + end slot) renders under the input
|
|
53
|
+
// only when there's something to show; otherwise the grid is just input | send.
|
|
54
|
+
const hasSlots =
|
|
55
|
+
showModelSelector || Boolean(startToolbarSlot) || Boolean(endToolbarSlot)
|
|
56
|
+
|
|
28
57
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
29
58
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
30
59
|
e.preventDefault()
|
|
@@ -34,41 +63,100 @@ export function ChatFooter({
|
|
|
34
63
|
}
|
|
35
64
|
}
|
|
36
65
|
|
|
66
|
+
const sendButton =
|
|
67
|
+
isGenerating && onStop ? (
|
|
68
|
+
<IconButton
|
|
69
|
+
size='small'
|
|
70
|
+
onClick={onStop}
|
|
71
|
+
disabled={disabled}
|
|
72
|
+
aria-label={labels.stop ?? 'Stop'}
|
|
73
|
+
>
|
|
74
|
+
<StopCircleOutlined />
|
|
75
|
+
</IconButton>
|
|
76
|
+
) : (
|
|
77
|
+
<IconButton
|
|
78
|
+
size='small'
|
|
79
|
+
variant='contained'
|
|
80
|
+
color='primary'
|
|
81
|
+
onClick={onSend}
|
|
82
|
+
disabled={!canSend}
|
|
83
|
+
aria-label={labels.send ?? 'Send'}
|
|
84
|
+
>
|
|
85
|
+
<ArrowUpwardOutlined />
|
|
86
|
+
</IconButton>
|
|
87
|
+
)
|
|
88
|
+
|
|
37
89
|
return (
|
|
38
90
|
<FormControl fullWidth sx={{ ...styles.footerWrapper, ...sx }}>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
91
|
+
{/* Two grid areas: [input + slots] | [send button, aligned to the end]. */}
|
|
92
|
+
<Box sx={styles.footerBox}>
|
|
93
|
+
<Box sx={styles.footerMain}>
|
|
94
|
+
<InputBase
|
|
95
|
+
multiline
|
|
96
|
+
minRows={1}
|
|
97
|
+
maxRows={8}
|
|
98
|
+
value={value}
|
|
99
|
+
onChange={(e) => onChange(e.target.value)}
|
|
100
|
+
onKeyDown={handleKeyDown}
|
|
101
|
+
placeholder={placeholder}
|
|
102
|
+
disabled={disabled || isGenerating}
|
|
103
|
+
sx={styles.footerInput}
|
|
104
|
+
/>
|
|
105
|
+
{hasSlots && (
|
|
106
|
+
<Box sx={styles.footerSlots}>
|
|
107
|
+
{startToolbarSlot}
|
|
108
|
+
{showModelSelector && (
|
|
109
|
+
<>
|
|
110
|
+
<Button
|
|
111
|
+
size='small'
|
|
112
|
+
variant='text'
|
|
113
|
+
color='inherit'
|
|
114
|
+
disabled={disabled || isGenerating}
|
|
115
|
+
onClick={(e) => setModelAnchor(e.currentTarget)}
|
|
116
|
+
aria-label={modelLabel}
|
|
117
|
+
aria-haspopup='menu'
|
|
118
|
+
aria-expanded={modelAnchor ? 'true' : undefined}
|
|
119
|
+
endIcon={<KeyboardArrowDown />}
|
|
120
|
+
sx={styles.modelSelector}
|
|
121
|
+
>
|
|
122
|
+
<Typography
|
|
123
|
+
variant='caption'
|
|
124
|
+
component='span'
|
|
125
|
+
color='inherit'
|
|
126
|
+
sx={styles.modelSelectorLabel}
|
|
127
|
+
>
|
|
128
|
+
{selectedLabel}
|
|
129
|
+
</Typography>
|
|
130
|
+
</Button>
|
|
131
|
+
<Menu
|
|
132
|
+
anchorEl={modelAnchor}
|
|
133
|
+
open={Boolean(modelAnchor)}
|
|
134
|
+
onClose={() => setModelAnchor(null)}
|
|
135
|
+
variant='menu'
|
|
136
|
+
elevation={8}
|
|
137
|
+
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
|
|
138
|
+
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
|
139
|
+
>
|
|
140
|
+
{models?.map((m) => (
|
|
141
|
+
<MenuItem
|
|
142
|
+
key={m.value}
|
|
143
|
+
selected={m.value === selectedModel}
|
|
144
|
+
onClick={() => {
|
|
145
|
+
onModelChange?.(m.value)
|
|
146
|
+
setModelAnchor(null)
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
{m.label}
|
|
150
|
+
</MenuItem>
|
|
151
|
+
))}
|
|
152
|
+
</Menu>
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
155
|
+
{endToolbarSlot}
|
|
156
|
+
</Box>
|
|
157
|
+
)}
|
|
158
|
+
</Box>
|
|
159
|
+
<Box sx={styles.footerSend}>{sendButton}</Box>
|
|
72
160
|
</Box>
|
|
73
161
|
{caption ? (
|
|
74
162
|
<FormHelperText sx={styles.footerCaption}>{caption}</FormHelperText>
|
|
@@ -20,24 +20,115 @@ export const styles = {
|
|
|
20
20
|
},
|
|
21
21
|
footerWrapper: {
|
|
22
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
23
|
},
|
|
31
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Rounded input box, matching the reference ChatFooter. A two-column grid:
|
|
26
|
+
* main column (input + optional slots) | send button, bottom-aligned so it
|
|
27
|
+
* stays at the end as the input grows. NO vertical padding — the textarea's own
|
|
28
|
+
* padding sets the ~32px single-line height, so the box hugs the input.
|
|
29
|
+
*/
|
|
30
|
+
footerBox: {
|
|
32
31
|
maxWidth: CHAT_MAX_WIDTH,
|
|
32
|
+
width: '100%',
|
|
33
33
|
margin: '0 auto',
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
display: 'grid',
|
|
35
|
+
gridTemplateColumns: '1fr auto',
|
|
36
|
+
alignItems: 'end',
|
|
37
|
+
columnGap: ({ spacing }) => spacing(0.5),
|
|
38
|
+
backgroundColor: ({ palette }) =>
|
|
39
|
+
palette.default?.background ?? 'rgba(44, 48, 50, 0.04)',
|
|
40
|
+
borderRadius: ({ spacing }) => spacing(0.5),
|
|
41
|
+
paddingInline: ({ spacing }) => spacing(0.5),
|
|
42
|
+
},
|
|
43
|
+
/** Main grid area: input stacked above the (optional) slots row. */
|
|
44
|
+
footerMain: {
|
|
45
|
+
minWidth: 0,
|
|
46
|
+
display: 'flex',
|
|
47
|
+
flexDirection: 'column',
|
|
48
|
+
gap: ({ spacing }) => spacing(0.5),
|
|
49
|
+
},
|
|
50
|
+
/**
|
|
51
|
+
* Send-button grid area, bottom-aligned (matches the reference flex-end row) so
|
|
52
|
+
* the button stays at the bottom as the input grows. The small bottom padding
|
|
53
|
+
* keeps it off the box edge — in the single-line state it reads as vertically
|
|
54
|
+
* centered (4px top / 4px bottom around the 24px button in the 32px row).
|
|
55
|
+
*/
|
|
56
|
+
footerSend: {
|
|
57
|
+
alignSelf: 'end',
|
|
58
|
+
paddingBottom: ({ spacing }) => spacing(0.5),
|
|
59
|
+
'& .MuiIconButton-root': {
|
|
60
|
+
padding: ({ spacing }) => spacing(0.5),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
/** The chrome-less multiline input (no underline / no filled affordance). */
|
|
64
|
+
footerInput: {
|
|
65
|
+
width: '100%',
|
|
66
|
+
typography: 'body2',
|
|
67
|
+
// The Meridian theme styles multiline InputBase as a tall "comment field"
|
|
68
|
+
// (min-height: 96px on the root, 12px/14px textarea padding). Override both to
|
|
69
|
+
// get the compact chat field: kill the root min-height and use the reference's
|
|
70
|
+
// 6px/10px padding — both need !important to beat the theme override. Height
|
|
71
|
+
// then comes from MUI's TextareaAutosize (minRows=1/maxRows=8): one line
|
|
72
|
+
// (~32px) → grows → scrolls.
|
|
73
|
+
//
|
|
74
|
+
// TextareaAutosize renders a hidden shadow <textarea> (it carries
|
|
75
|
+
// `aria-hidden="true"`, the same class, and an inline `padding: 0`) to measure
|
|
76
|
+
// the content height; MUI then applies that height to the visible input. Getting
|
|
77
|
+
// the compact ~32px single line right needs the two rules below split apart:
|
|
78
|
+
//
|
|
79
|
+
// • Font metrics (size/line-height/letter-spacing) go on BOTH textareas. The
|
|
80
|
+
// shadow measures with its own CSS, so if it isn't pinned to body2 it
|
|
81
|
+
// measures at the default ~24px line-height and the box ends up too tall.
|
|
82
|
+
// • Padding goes on the VISIBLE input ONLY (`:not([aria-hidden])`). A
|
|
83
|
+
// `!important` padding that also hit the shadow would clobber its measurement
|
|
84
|
+
// `padding: 0`, double-counting the padding (~44px instead of 32px).
|
|
85
|
+
//
|
|
86
|
+
// `!important` is required on both to beat the Meridian theme's tall
|
|
87
|
+
// "comment field" multiline overrides (12px/14px padding, larger metrics); the
|
|
88
|
+
// root `min-height: 0` kills that theme's 96px min-height.
|
|
89
|
+
minHeight: '0 !important',
|
|
90
|
+
'& textarea': {
|
|
91
|
+
fontSize: ({ typography }) => `${typography.body2.fontSize} !important`,
|
|
92
|
+
lineHeight: ({ typography }) =>
|
|
93
|
+
`${typography.body2.lineHeight} !important`,
|
|
94
|
+
letterSpacing: ({ typography }) =>
|
|
95
|
+
`${typography.body2.letterSpacing} !important`,
|
|
96
|
+
},
|
|
97
|
+
'& textarea:not([aria-hidden])': {
|
|
98
|
+
resize: 'none',
|
|
99
|
+
padding: ({ spacing }) => `${spacing(0.75, 1.25)} !important`,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
/**
|
|
103
|
+
* Slots row under the input: start slot + model selector + end slot. The left
|
|
104
|
+
* padding lines the row's content up with the input text above it: the input
|
|
105
|
+
* text is inset ~12px from the box edge (InputBase root padding + the textarea's
|
|
106
|
+
* 10px left padding) while the model-selector text button only insets ~8px, so a
|
|
107
|
+
* 4px indent here makes the slot label share the placeholder's left edge instead
|
|
108
|
+
* of sitting further out.
|
|
109
|
+
*/
|
|
110
|
+
footerSlots: {
|
|
111
|
+
display: 'flex',
|
|
112
|
+
alignItems: 'center',
|
|
113
|
+
gap: ({ spacing }) => spacing(0.5),
|
|
114
|
+
minWidth: 0,
|
|
115
|
+
flexWrap: 'wrap',
|
|
116
|
+
paddingLeft: ({ spacing }) => spacing(0.5),
|
|
117
|
+
paddingBottom: ({ spacing }) => spacing(0.5),
|
|
118
|
+
},
|
|
119
|
+
modelSelector: {
|
|
120
|
+
textTransform: 'none',
|
|
121
|
+
color: ({ palette }) => palette.text.secondary,
|
|
122
|
+
maxWidth: '100%',
|
|
123
|
+
minWidth: 0,
|
|
124
|
+
'& .MuiButton-endIcon': {
|
|
125
|
+
marginLeft: ({ spacing }) => spacing(0.25),
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
modelSelectorLabel: {
|
|
129
|
+
overflow: 'hidden',
|
|
130
|
+
textOverflow: 'ellipsis',
|
|
131
|
+
whiteSpace: 'nowrap',
|
|
41
132
|
},
|
|
42
133
|
footerCaption: {
|
|
43
134
|
textAlign: 'center',
|
|
@@ -51,7 +142,7 @@ export const styles = {
|
|
|
51
142
|
width: '100%',
|
|
52
143
|
maxHeight: '100%',
|
|
53
144
|
pt: 1,
|
|
54
|
-
pb:
|
|
145
|
+
pb: 3,
|
|
55
146
|
px: 2,
|
|
56
147
|
display: 'flex',
|
|
57
148
|
flexDirection: 'column',
|
|
@@ -121,8 +121,12 @@ export const styles = {
|
|
|
121
121
|
padding: ({ spacing }) => spacing(1),
|
|
122
122
|
borderRadius: ({ spacing }) => spacing(0.5),
|
|
123
123
|
backgroundColor: ({ palette }) => palette.background.default,
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
fontFamily: ({ typography }) =>
|
|
125
|
+
typography.code3?.fontFamily ??
|
|
126
|
+
typography.code1?.fontFamily ??
|
|
127
|
+
'monospace',
|
|
128
|
+
fontSize: ({ typography }) => typography.code3?.fontSize ?? '0.75rem',
|
|
129
|
+
lineHeight: ({ typography }) => typography.code3?.lineHeight ?? 1.333,
|
|
126
130
|
whiteSpace: 'pre-wrap',
|
|
127
131
|
wordBreak: 'break-word',
|
|
128
132
|
overflowY: 'auto',
|
|
@@ -160,8 +164,11 @@ export const styles = {
|
|
|
160
164
|
padding: ({ spacing }) => spacing(1),
|
|
161
165
|
background: ({ palette, spacing }) =>
|
|
162
166
|
`linear-gradient(to right, ${palette.action.hover} calc(${spacing(1)} + 3em), ${palette.background.default} calc(${spacing(1)} + 3em))`,
|
|
163
|
-
fontFamily:
|
|
164
|
-
|
|
167
|
+
fontFamily: ({ typography }) =>
|
|
168
|
+
typography.code3?.fontFamily ??
|
|
169
|
+
typography.code1?.fontFamily ??
|
|
170
|
+
'monospace',
|
|
171
|
+
fontSize: ({ typography }) => typography.code3?.fontSize ?? '0.75rem',
|
|
165
172
|
whiteSpace: 'pre-wrap',
|
|
166
173
|
wordBreak: 'break-word',
|
|
167
174
|
lineHeight: 1.6,
|
package/src/chat/index.ts
CHANGED
package/src/chat/types.ts
CHANGED
|
@@ -110,9 +110,31 @@ export interface ChatFooterProps extends ChatSxProps {
|
|
|
110
110
|
send?: string
|
|
111
111
|
/** Defaults to `'Stop'`. */
|
|
112
112
|
stop?: string
|
|
113
|
+
/** `aria-label` for the model-selector trigger. Defaults to `'Select model'`. */
|
|
114
|
+
model?: string
|
|
113
115
|
}
|
|
114
116
|
/** Helper text rendered under the input. Defaults to an AI disclaimer; pass `null` to hide. */
|
|
115
117
|
caption?: ReactNode
|
|
118
|
+
/**
|
|
119
|
+
* Selectable models for the in-toolbar model selector. The selector is only
|
|
120
|
+
* rendered when this is a non-empty array; the component is otherwise
|
|
121
|
+
* model-agnostic (the host owns the list, selection, and default).
|
|
122
|
+
*/
|
|
123
|
+
models?: ChatModelOption[]
|
|
124
|
+
/** Currently selected model `value`. Controlled — the host owns the state. */
|
|
125
|
+
selectedModel?: string
|
|
126
|
+
/** Called with the picked model `value` when the user selects one. */
|
|
127
|
+
onModelChange?: (value: string) => void
|
|
128
|
+
/** Extra controls rendered at the start (left) of the toolbar, before the model selector. */
|
|
129
|
+
startToolbarSlot?: ReactNode
|
|
130
|
+
/** Extra controls rendered at the end (right) of the toolbar, before the send/stop button. */
|
|
131
|
+
endToolbarSlot?: ReactNode
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** An option for the `ChatFooter` model selector. */
|
|
135
|
+
export interface ChatModelOption {
|
|
136
|
+
value: string
|
|
137
|
+
label: string
|
|
116
138
|
}
|
|
117
139
|
|
|
118
140
|
// === Extras props ===
|
|
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
3
3
|
interface UseTypewriterOptions {
|
|
4
4
|
/** Characters revealed per second (default: `500`). */
|
|
5
5
|
speed?: number
|
|
6
|
-
/** When true
|
|
6
|
+
/** When true, reveal the full text immediately (and snap to it if it grows). */
|
|
7
7
|
skipAnimation?: boolean
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -16,8 +16,10 @@ interface UseTypewriterResult {
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Reveals a string character-by-character at a steady rate via
|
|
19
|
-
* `requestAnimationFrame`.
|
|
20
|
-
*
|
|
19
|
+
* `requestAnimationFrame`. Designed for bursty, streamed agent text: as
|
|
20
|
+
* `fullText` grows the reveal keeps chasing the new end, and flipping
|
|
21
|
+
* `skipAnimation` to `true` (e.g. once generation finishes) snaps to the full
|
|
22
|
+
* text. Pair it with `ChatAgentMessage`.
|
|
21
23
|
*
|
|
22
24
|
* @example
|
|
23
25
|
* ```tsx
|
|
@@ -36,47 +38,53 @@ export function useTypewriter(
|
|
|
36
38
|
): UseTypewriterResult {
|
|
37
39
|
const { speed = 500, skipAnimation = false } = options
|
|
38
40
|
|
|
39
|
-
const skipRef = useRef(skipAnimation)
|
|
40
|
-
|
|
41
41
|
const [charIndex, setCharIndex] = useState(() =>
|
|
42
|
-
|
|
42
|
+
skipAnimation ? fullText.length : 0,
|
|
43
43
|
)
|
|
44
44
|
|
|
45
|
+
// Mirror of the revealed count, read/written ONLY inside the rAF loop (never
|
|
46
|
+
// during render). This lets the animation effect omit `charIndex` from its
|
|
47
|
+
// deps: were it a dep, the loop would tear down and restart on every revealed
|
|
48
|
+
// frame — resetting its frame timer — and never accumulate the elapsed time
|
|
49
|
+
// needed to advance, which stalls completely while `fullText` is also growing
|
|
50
|
+
// (the streaming case).
|
|
51
|
+
const charRef = useRef(charIndex)
|
|
52
|
+
|
|
45
53
|
useEffect(() => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
// When `skipAnimation` is set there's nothing to animate — the full text is
|
|
55
|
+
// shown directly via the derived `displayedText` below (no setState here, so
|
|
56
|
+
// a streamed message snaps to its full text the moment generation finishes,
|
|
57
|
+
// without an extra render or a `set-state-in-effect` cascade).
|
|
58
|
+
if (skipAnimation) return
|
|
50
59
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
60
|
+
// Already fully revealed (e.g. text hasn't grown since the last frame).
|
|
61
|
+
if (charRef.current >= fullText.length) return
|
|
54
62
|
|
|
55
63
|
const msPerChar = 1000 / speed
|
|
56
64
|
let rafId: number
|
|
57
65
|
let lastTime: number | null = null
|
|
58
66
|
|
|
59
67
|
function tick(timestamp: number) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const charsToAdd = Math.floor(
|
|
64
|
-
|
|
68
|
+
// Plain `?? =` not `??=`: the React Compiler can't lower logical-assignment
|
|
69
|
+
// operators and would bail this hook out of optimization.
|
|
70
|
+
lastTime = lastTime ?? timestamp
|
|
71
|
+
const charsToAdd = Math.floor((timestamp - lastTime) / msPerChar)
|
|
65
72
|
if (charsToAdd > 0) {
|
|
66
73
|
lastTime = timestamp
|
|
67
|
-
|
|
74
|
+
const next = Math.min(charRef.current + charsToAdd, fullText.length)
|
|
75
|
+
charRef.current = next
|
|
76
|
+
setCharIndex(next)
|
|
77
|
+
if (next >= fullText.length) return // fully revealed — stop the loop
|
|
68
78
|
}
|
|
69
|
-
|
|
70
79
|
rafId = requestAnimationFrame(tick)
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
rafId = requestAnimationFrame(tick)
|
|
74
|
-
|
|
75
83
|
return () => cancelAnimationFrame(rafId)
|
|
76
|
-
}, [fullText,
|
|
84
|
+
}, [fullText, speed, skipAnimation])
|
|
77
85
|
|
|
78
86
|
return {
|
|
79
|
-
displayedText: fullText.slice(0, charIndex),
|
|
80
|
-
isTyping: charIndex < fullText.length,
|
|
87
|
+
displayedText: skipAnimation ? fullText : fullText.slice(0, charIndex),
|
|
88
|
+
isTyping: !skipAnimation && charIndex < fullText.length,
|
|
81
89
|
}
|
|
82
90
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
buildHistogramSeriesLabelConfig,
|
|
8
8
|
buildLegendConfig,
|
|
9
9
|
buildGridConfig,
|
|
10
|
+
buildAxisLabelStyle,
|
|
10
11
|
createTooltipPositioner,
|
|
11
12
|
createAxisLabelFormatter,
|
|
12
13
|
applyXAxisFormatter,
|
|
@@ -195,6 +196,39 @@ describe('buildGridConfig', () => {
|
|
|
195
196
|
})
|
|
196
197
|
})
|
|
197
198
|
|
|
199
|
+
describe('buildAxisLabelStyle', () => {
|
|
200
|
+
const themeWithTokens = {
|
|
201
|
+
typography: {
|
|
202
|
+
overlineDelicate: {
|
|
203
|
+
fontSize: '0.625rem',
|
|
204
|
+
fontFamily: 'Inter, sans-serif',
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
palette: {
|
|
208
|
+
black: { 60: 'rgba(44,48,50,0.6)' },
|
|
209
|
+
text: { secondary: 'rgba(0,0,0,0.6)' },
|
|
210
|
+
},
|
|
211
|
+
} as unknown as Theme
|
|
212
|
+
|
|
213
|
+
it('returns the overlineDelicate font + black[60] colour', () => {
|
|
214
|
+
expect(buildAxisLabelStyle(themeWithTokens)).toEqual({
|
|
215
|
+
fontSize: '0.625rem',
|
|
216
|
+
fontFamily: 'Inter, sans-serif',
|
|
217
|
+
color: 'rgba(44,48,50,0.6)',
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('falls back to text.secondary when black[60] is absent', () => {
|
|
222
|
+
const themeNoBlack = {
|
|
223
|
+
typography: {
|
|
224
|
+
overlineDelicate: { fontSize: '0.625rem', fontFamily: 'Inter' },
|
|
225
|
+
},
|
|
226
|
+
palette: { text: { secondary: 'rgba(0,0,0,0.6)' } },
|
|
227
|
+
} as unknown as Theme
|
|
228
|
+
expect(buildAxisLabelStyle(themeNoBlack).color).toBe('rgba(0,0,0,0.6)')
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
198
232
|
describe('createTooltipPositioner', () => {
|
|
199
233
|
it('returns a positioner that places tooltip on the left when there is room', () => {
|
|
200
234
|
const positioner = createTooltipPositioner(fakeTheme)
|
|
@@ -66,6 +66,27 @@ export function buildGridConfig(hasLegend: boolean, theme: Theme) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Builds the shared axis-label text style for chart widgets, matching the
|
|
71
|
+
* design's "Overline (Delicate)" token (Inter, 10px) in the secondary text
|
|
72
|
+
* colour (`rgba(44,48,50,0.6)`). Spread into an ECharts `axisLabel` for both
|
|
73
|
+
* x and y axes so every chart's labels stay consistent.
|
|
74
|
+
*
|
|
75
|
+
* Note: the token's `letterSpacing` and `textTransform: uppercase` are not
|
|
76
|
+
* representable in ECharts canvas text, so only font size/family/colour are
|
|
77
|
+
* applied.
|
|
78
|
+
*
|
|
79
|
+
* @param theme - MUI theme providing the typography token and palette
|
|
80
|
+
* @returns `{ fontSize, fontFamily, color }` for an ECharts `axisLabel`
|
|
81
|
+
*/
|
|
82
|
+
export function buildAxisLabelStyle(theme: Theme) {
|
|
83
|
+
return {
|
|
84
|
+
fontSize: theme.typography.overlineDelicate?.fontSize,
|
|
85
|
+
fontFamily: theme.typography.overlineDelicate?.fontFamily,
|
|
86
|
+
color: theme.palette.black?.[60] ?? theme.palette.text.secondary,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
/**
|
|
70
91
|
* Creates a tooltip position calculator that handles overflow
|
|
71
92
|
* Used by bar, histogram, and scatterplot widgets
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical `DownloadItem.id`s for the built-in download formats. Centralised
|
|
3
|
+
* so the per-format builders — and anything that keys off the id (menu logic,
|
|
4
|
+
* tests) — reference one source of truth instead of re-typing string literals.
|
|
5
|
+
* The Markdown format keeps the short `'md'` id to match its `.md` extension.
|
|
6
|
+
*/
|
|
7
|
+
export const DOWNLOAD_ITEM_IDS = {
|
|
8
|
+
png: 'png',
|
|
9
|
+
csv: 'csv',
|
|
10
|
+
markdown: 'md',
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
export type DownloadItemId =
|
|
14
|
+
(typeof DOWNLOAD_ITEM_IDS)[keyof typeof DOWNLOAD_ITEM_IDS]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { buildCsvDownloadItem } from './csv-item'
|
|
3
|
+
|
|
4
|
+
let csvText = ''
|
|
5
|
+
let revokeSpy: ReturnType<typeof vi.spyOn>
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
csvText = ''
|
|
9
|
+
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:csv')
|
|
10
|
+
revokeSpy = vi
|
|
11
|
+
.spyOn(URL, 'revokeObjectURL')
|
|
12
|
+
.mockImplementation(() => undefined)
|
|
13
|
+
const RealBlob = global.Blob
|
|
14
|
+
vi.stubGlobal(
|
|
15
|
+
'Blob',
|
|
16
|
+
class extends RealBlob {
|
|
17
|
+
constructor(parts: BlobPart[], opts?: BlobPropertyBag) {
|
|
18
|
+
csvText = typeof parts[0] === 'string' ? parts[0] : ''
|
|
19
|
+
super(parts, opts)
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('buildCsvDownloadItem', () => {
|
|
26
|
+
it('returns an item with the canonical CSV shape (id, label, icon)', () => {
|
|
27
|
+
const item = buildCsvDownloadItem({ filename: 'demo', getRows: () => [] })
|
|
28
|
+
expect(item.id).toBe('csv')
|
|
29
|
+
expect(item.label).toBe('CSV')
|
|
30
|
+
expect(item.icon).toBeTruthy()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('honours a label override', () => {
|
|
34
|
+
const item = buildCsvDownloadItem({
|
|
35
|
+
filename: 'demo',
|
|
36
|
+
getRows: () => [],
|
|
37
|
+
label: 'Export data',
|
|
38
|
+
})
|
|
39
|
+
expect(item.label).toBe('Export data')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('getRows path serialises rows via toCsvString and returns a .csv handle', async () => {
|
|
43
|
+
const item = buildCsvDownloadItem({
|
|
44
|
+
filename: 'sales',
|
|
45
|
+
getRows: () => [
|
|
46
|
+
['name', 'value'],
|
|
47
|
+
['a', 1],
|
|
48
|
+
],
|
|
49
|
+
})
|
|
50
|
+
const handle = await item.resolve()
|
|
51
|
+
expect(handle.url).toBe('blob:csv')
|
|
52
|
+
expect(handle.filename).toBe('sales.csv')
|
|
53
|
+
expect(csvText).toBe('name,value\na,1')
|
|
54
|
+
handle.revoke?.()
|
|
55
|
+
expect(revokeSpy).toHaveBeenCalledWith('blob:csv')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('getRows path applies the formula-injection guard', async () => {
|
|
59
|
+
const item = buildCsvDownloadItem({
|
|
60
|
+
filename: 'sales',
|
|
61
|
+
getRows: () => [['=SUM(A1)']],
|
|
62
|
+
})
|
|
63
|
+
await item.resolve()
|
|
64
|
+
expect(csvText).toBe("'=SUM(A1)")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('getCsv path passes the pre-built CSV through verbatim', async () => {
|
|
68
|
+
const item = buildCsvDownloadItem({
|
|
69
|
+
filename: 'table',
|
|
70
|
+
getCsv: () => 'col\n=raw',
|
|
71
|
+
})
|
|
72
|
+
const handle = await item.resolve()
|
|
73
|
+
expect(handle.filename).toBe('table.csv')
|
|
74
|
+
// getCsv is an escape hatch — content is used as-is, no extra escaping.
|
|
75
|
+
expect(csvText).toBe('col\n=raw')
|
|
76
|
+
})
|
|
77
|
+
})
|