@inglorious/ui 9.3.0 → 9.5.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/package.json +9 -4
- package/src/controls/textarea/style.css +7 -2
- package/src/controls/textarea/template.js +4 -1
- package/src/data-display/chat/chat.stories.js +90 -0
- package/src/data-display/chat/handlers.js +95 -0
- package/src/data-display/chat/index.js +19 -0
- package/src/data-display/chat/style.css +114 -0
- package/src/data-display/chat/template.js +209 -0
- package/src/data-display/data-display.css +1 -0
- package/src/data-display/data-grid/base.css +9 -2
- package/src/data-display/data-grid/filters/range.js +0 -2
- package/src/data-display/data-grid/handlers.js +13 -2
- package/src/data-display/data-grid/template.js +56 -7
- package/src/index.js +1 -0
- package/types/controls/textarea.d.ts +4 -0
- package/types/data-display/chat.d.ts +73 -0
- package/types/data-display/data-grid.d.ts +5 -1
- package/types/index.d.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/ui",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.5.1",
|
|
4
4
|
"description": "A design system for Inglorious Web with render-function components and customizable themes",
|
|
5
5
|
"author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -54,6 +54,11 @@
|
|
|
54
54
|
"import": "./src/controls/button-group/index.js"
|
|
55
55
|
},
|
|
56
56
|
"./button-group.css": "./src/controls/button-group/style.css",
|
|
57
|
+
"./chat": {
|
|
58
|
+
"types": "./types/data-display/chat.d.ts",
|
|
59
|
+
"import": "./src/data-display/chat/index.js"
|
|
60
|
+
},
|
|
61
|
+
"./chat.css": "./src/data-display/chat/style.css",
|
|
57
62
|
"./carousel": {
|
|
58
63
|
"types": "./types/data-display/carousel.d.ts",
|
|
59
64
|
"import": "./src/data-display/carousel/index.js"
|
|
@@ -308,19 +313,19 @@
|
|
|
308
313
|
"peerDependencies": {
|
|
309
314
|
"@inglorious/store": "12.0.0",
|
|
310
315
|
"@inglorious/utils": "5.1.0",
|
|
311
|
-
"@inglorious/web": "6.9.
|
|
316
|
+
"@inglorious/web": "6.9.1"
|
|
312
317
|
},
|
|
313
318
|
"devDependencies": {
|
|
314
319
|
"@iconify-icons/fa7-brands": "^2.0.0",
|
|
315
320
|
"@iconify-icons/fa7-regular": "^2.0.0",
|
|
316
321
|
"@iconify-icons/fa7-solid": "^2.0.0",
|
|
317
322
|
"@iconify-icons/material-symbols": "^2.0.6",
|
|
318
|
-
"@inglorious/charts": "12.2.
|
|
323
|
+
"@inglorious/charts": "12.2.2",
|
|
319
324
|
"@inglorious/eslint-config": "1.2.0",
|
|
320
325
|
"@inglorious/store": "12.0.0",
|
|
321
326
|
"@inglorious/vite-plugin-jsx": "2.0.5",
|
|
322
327
|
"@inglorious/vite-plugin-vue": "1.0.6",
|
|
323
|
-
"@inglorious/web": "6.9.
|
|
328
|
+
"@inglorious/web": "6.9.1",
|
|
324
329
|
"@storybook/addon-docs": "^10.6.0",
|
|
325
330
|
"@storybook/web-components-vite": "^10.6.0",
|
|
326
331
|
"happy-dom": "^20.14.0",
|
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
gap: var(--iw-space-1);
|
|
5
5
|
font-family: var(--iw-font-family-sans);
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/* The field takes the width it is given, and the box inside fills it. */
|
|
8
|
+
&.iw-textarea-full-width {
|
|
8
9
|
width: 100%;
|
|
10
|
+
|
|
11
|
+
.iw-textarea {
|
|
12
|
+
width: 100%;
|
|
13
|
+
}
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
.iw-textarea-label {
|
|
@@ -19,8 +24,8 @@
|
|
|
19
24
|
margin-inline-start: var(--iw-space-1);
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
/* Its height is its rows', or its content's with isAutoSize. */
|
|
22
28
|
.iw-textarea {
|
|
23
|
-
min-height: 3rem;
|
|
24
29
|
width: min(100%, 100%);
|
|
25
30
|
resize: none;
|
|
26
31
|
padding: var(--iw-space-2) var(--iw-space-3);
|
|
@@ -24,6 +24,7 @@ export function render(props) {
|
|
|
24
24
|
value = "",
|
|
25
25
|
placeholder = "",
|
|
26
26
|
label,
|
|
27
|
+
ariaLabel,
|
|
27
28
|
hint,
|
|
28
29
|
error,
|
|
29
30
|
size = "md",
|
|
@@ -37,6 +38,7 @@ export function render(props) {
|
|
|
37
38
|
onChange,
|
|
38
39
|
onBlur,
|
|
39
40
|
onFocus,
|
|
41
|
+
onKeyDown,
|
|
40
42
|
...rest
|
|
41
43
|
} = props
|
|
42
44
|
|
|
@@ -76,7 +78,7 @@ export function render(props) {
|
|
|
76
78
|
<textarea
|
|
77
79
|
id=${textareaId || nothing}
|
|
78
80
|
name=${name}
|
|
79
|
-
aria-label=${fallbackLabel(textareaId, label)}
|
|
81
|
+
aria-label=${ariaLabel || fallbackLabel(textareaId, label)}
|
|
80
82
|
aria-describedby=${messageId || nothing}
|
|
81
83
|
aria-invalid=${error ? "true" : "false"}
|
|
82
84
|
rows=${rows}
|
|
@@ -89,6 +91,7 @@ export function render(props) {
|
|
|
89
91
|
@input=${(event) => onChange?.(event.target.value)}
|
|
90
92
|
@blur=${onBlur}
|
|
91
93
|
@focus=${onFocus}
|
|
94
|
+
@keydown=${onKeyDown}
|
|
92
95
|
${ref((element) => applyElementProps(element, rest))}
|
|
93
96
|
></textarea>
|
|
94
97
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEntityRender,
|
|
3
|
+
notifyActionArgType,
|
|
4
|
+
} from "../../stories/notify-story.js"
|
|
5
|
+
import { Chat } from "."
|
|
6
|
+
|
|
7
|
+
/** A chat fills its container, which sets its height. */
|
|
8
|
+
const withHeight = (story) => {
|
|
9
|
+
const frame = document.createElement("div")
|
|
10
|
+
frame.style.height = "28rem"
|
|
11
|
+
const chat = story()
|
|
12
|
+
chat.style.height = "100%"
|
|
13
|
+
frame.append(chat)
|
|
14
|
+
return frame
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const conversation = [
|
|
18
|
+
{ id: "m1", role: "user", content: "Where did my money go this month?" },
|
|
19
|
+
{
|
|
20
|
+
id: "m2",
|
|
21
|
+
role: "assistant",
|
|
22
|
+
content:
|
|
23
|
+
"Rent took €900 of the €1,076 you spent. Food came next, at €173, then transport at €58.",
|
|
24
|
+
},
|
|
25
|
+
{ id: "m3", role: "user", content: "How can I spend less on food?" },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export default {
|
|
29
|
+
title: "Data Display/Chat",
|
|
30
|
+
tags: ["autodocs"],
|
|
31
|
+
render: createEntityRender({ Chat }),
|
|
32
|
+
decorators: [withHeight],
|
|
33
|
+
argTypes: {
|
|
34
|
+
...notifyActionArgType,
|
|
35
|
+
messages: {
|
|
36
|
+
control: "object",
|
|
37
|
+
description:
|
|
38
|
+
"Messages: `{ id, role, content }`, or `{ id, role, entity }` to render another entity, such as a generated answer.",
|
|
39
|
+
},
|
|
40
|
+
draft: { control: "text", description: "What the user is typing." },
|
|
41
|
+
isBusy: {
|
|
42
|
+
control: "boolean",
|
|
43
|
+
description: "A reply is on its way: shows it, and holds sending.",
|
|
44
|
+
},
|
|
45
|
+
error: { control: "text", description: "Why the last reply failed." },
|
|
46
|
+
suggestions: {
|
|
47
|
+
control: "object",
|
|
48
|
+
description: "Messages to start with, while the chat is empty.",
|
|
49
|
+
},
|
|
50
|
+
placeholder: { control: "text" },
|
|
51
|
+
sendLabel: { control: "text" },
|
|
52
|
+
},
|
|
53
|
+
parameters: {
|
|
54
|
+
docs: {
|
|
55
|
+
description: {
|
|
56
|
+
component:
|
|
57
|
+
"A conversation and a box to write in. The chat keeps the messages and the draft; the app answers by handling `messageSend`, and adds replies with `messageAdd`, `replyStart` and `replyEnd`. Enter sends, Shift+Enter starts a new line.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const Default = {
|
|
64
|
+
args: { id: "chat", type: "Chat", messages: conversation },
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const Suggestions = {
|
|
68
|
+
args: {
|
|
69
|
+
id: "chatSuggestions",
|
|
70
|
+
type: "Chat",
|
|
71
|
+
messages: [],
|
|
72
|
+
suggestions: [
|
|
73
|
+
"Where did my money go this month?",
|
|
74
|
+
"Show my food expenses",
|
|
75
|
+
"I want to add an expense",
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const Replying = {
|
|
81
|
+
args: { ...Default.args, id: "chatReplying", isBusy: true },
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const Failed = {
|
|
85
|
+
args: {
|
|
86
|
+
...Default.args,
|
|
87
|
+
id: "chatFailed",
|
|
88
|
+
error: "Could not reach the model. Is Ollama running?",
|
|
89
|
+
},
|
|
90
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('../../../types/data-display/chat').ChatEntity} ChatEntity
|
|
3
|
+
* @typedef {import('../../../types/data-display/chat').ChatMessage} ChatMessage
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fills in a chat's defaults. A chat keeps its messages and the draft being
|
|
8
|
+
* typed; who answers, and how, is the app's to decide by handling
|
|
9
|
+
* `messageSend`.
|
|
10
|
+
* @param {ChatEntity} entity
|
|
11
|
+
*/
|
|
12
|
+
export function create(entity) {
|
|
13
|
+
entity.messages ??= []
|
|
14
|
+
entity.draft ??= ""
|
|
15
|
+
entity.isBusy ??= false
|
|
16
|
+
entity.error ??= null
|
|
17
|
+
entity.suggestions ??= []
|
|
18
|
+
entity.messageCount ??= entity.messages.length
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Keeps what the user is typing.
|
|
23
|
+
* @param {ChatEntity} entity
|
|
24
|
+
* @param {string} draft
|
|
25
|
+
*/
|
|
26
|
+
export function draftChange(entity, draft) {
|
|
27
|
+
entity.draft = draft
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Adds the user's message: the text given, such as a suggestion picked, or
|
|
32
|
+
* else the draft, which it clears. Nothing is sent while a reply is coming,
|
|
33
|
+
* nor an empty message. Apps answer by handling this event after the chat.
|
|
34
|
+
* @param {ChatEntity} entity
|
|
35
|
+
* @param {string} [text]
|
|
36
|
+
*/
|
|
37
|
+
export function messageSend(entity, text) {
|
|
38
|
+
const content = (text ?? entity.draft).trim()
|
|
39
|
+
if (!content || entity.isBusy) return
|
|
40
|
+
|
|
41
|
+
addMessage(entity, { role: "user", content })
|
|
42
|
+
entity.draft = ""
|
|
43
|
+
entity.error = null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Adds a message, such as the assistant's reply: its text as `content`, or
|
|
48
|
+
* the id of an entity that renders it as `entity`.
|
|
49
|
+
* @param {ChatEntity} entity
|
|
50
|
+
* @param {Partial<ChatMessage>} message
|
|
51
|
+
*/
|
|
52
|
+
export function messageAdd(entity, message) {
|
|
53
|
+
addMessage(entity, { role: "assistant", ...message })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A reply is on its way: the chat says so, and waits for it to send again.
|
|
58
|
+
* @param {ChatEntity} entity
|
|
59
|
+
*/
|
|
60
|
+
export function replyStart(entity) {
|
|
61
|
+
entity.isBusy = true
|
|
62
|
+
entity.error = null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The reply is whole.
|
|
67
|
+
* @param {ChatEntity} entity
|
|
68
|
+
*/
|
|
69
|
+
export function replyEnd(entity) {
|
|
70
|
+
entity.isBusy = false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The reply failed: the chat shows why, and can send again.
|
|
75
|
+
* @param {ChatEntity} entity
|
|
76
|
+
* @param {string} error
|
|
77
|
+
*/
|
|
78
|
+
export function replyFail(entity, error) {
|
|
79
|
+
entity.isBusy = false
|
|
80
|
+
entity.error = error
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Starts the conversation over.
|
|
85
|
+
* @param {ChatEntity} entity
|
|
86
|
+
*/
|
|
87
|
+
export function messagesClear(entity) {
|
|
88
|
+
entity.messages = []
|
|
89
|
+
entity.error = null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function addMessage(entity, message) {
|
|
93
|
+
entity.messageCount = (entity.messageCount ?? entity.messages.length) + 1
|
|
94
|
+
entity.messages.push({ id: `m${entity.messageCount}`, ...message })
|
|
95
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as handlers from "./handlers.js"
|
|
2
|
+
import { Chat as renderers } from "./template.js"
|
|
3
|
+
|
|
4
|
+
export const Chat = {
|
|
5
|
+
...handlers,
|
|
6
|
+
...renderers,
|
|
7
|
+
render(entity, api) {
|
|
8
|
+
const id = entity.id
|
|
9
|
+
const props = {
|
|
10
|
+
...entity,
|
|
11
|
+
onDraftChange: (draft) => api.notify(`#${id}:draftChange`, draft),
|
|
12
|
+
onSend: (text) => api.notify(`#${id}:messageSend`, text),
|
|
13
|
+
// A message naming an entity, such as a generated answer, shows it.
|
|
14
|
+
renderEntity: (entityId) => api.render(entityId),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return this.renderChat(props)
|
|
18
|
+
},
|
|
19
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
@import "../../controls/button/style.css";
|
|
2
|
+
@import "../../controls/textarea/style.css";
|
|
3
|
+
@import "../../feedback/alert/style.css";
|
|
4
|
+
|
|
5
|
+
.iw-chat {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
gap: var(--iw-space-3);
|
|
9
|
+
block-size: 100%;
|
|
10
|
+
min-block-size: 0;
|
|
11
|
+
font-family: var(--iw-font-family-sans);
|
|
12
|
+
color: var(--iw-color-text);
|
|
13
|
+
|
|
14
|
+
.iw-chat-messages {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex: 1 1 auto;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
gap: var(--iw-space-4);
|
|
19
|
+
min-block-size: 0;
|
|
20
|
+
overflow-y: auto;
|
|
21
|
+
overscroll-behavior: contain;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.iw-chat-message {
|
|
25
|
+
display: flex;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.iw-chat-message-user {
|
|
29
|
+
justify-content: flex-end;
|
|
30
|
+
|
|
31
|
+
.iw-chat-message-content {
|
|
32
|
+
max-inline-size: 80%;
|
|
33
|
+
padding: var(--iw-space-2) var(--iw-space-3);
|
|
34
|
+
border-radius: var(--iw-radius-lg);
|
|
35
|
+
background: color-mix(in srgb, var(--iw-color-primary) 14%, transparent);
|
|
36
|
+
overflow-wrap: anywhere;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.iw-chat-message-assistant .iw-chat-message-content {
|
|
41
|
+
inline-size: 100%;
|
|
42
|
+
min-inline-size: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* A message's own line breaks show; the template's around it do not. */
|
|
46
|
+
.iw-chat-text {
|
|
47
|
+
white-space: pre-wrap;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.iw-chat-typing {
|
|
51
|
+
display: flex;
|
|
52
|
+
gap: var(--iw-space-1);
|
|
53
|
+
padding: var(--iw-space-2) 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.iw-chat-typing-dot {
|
|
57
|
+
inline-size: 0.5rem;
|
|
58
|
+
block-size: 0.5rem;
|
|
59
|
+
border-radius: 50%;
|
|
60
|
+
background: var(--iw-color-text-muted);
|
|
61
|
+
animation: iw-chat-typing 1.2s infinite ease-in-out;
|
|
62
|
+
|
|
63
|
+
&:nth-child(2) {
|
|
64
|
+
animation-delay: 0.15s;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
&:nth-child(3) {
|
|
68
|
+
animation-delay: 0.3s;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.iw-chat-suggestions {
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-wrap: wrap;
|
|
75
|
+
gap: var(--iw-space-2);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.iw-chat-composer {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: flex-end;
|
|
81
|
+
gap: var(--iw-space-2);
|
|
82
|
+
|
|
83
|
+
.iw-textarea-field {
|
|
84
|
+
flex: 1 1 auto;
|
|
85
|
+
min-inline-size: 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* The box grows with the message up to a point, then scrolls. */
|
|
89
|
+
.iw-textarea {
|
|
90
|
+
max-block-size: 10rem;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@keyframes iw-chat-typing {
|
|
96
|
+
0%,
|
|
97
|
+
80%,
|
|
98
|
+
100% {
|
|
99
|
+
opacity: 0.3;
|
|
100
|
+
transform: translateY(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
40% {
|
|
104
|
+
opacity: 1;
|
|
105
|
+
transform: translateY(-0.2rem);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@media (prefers-reduced-motion: reduce) {
|
|
110
|
+
.iw-chat .iw-chat-typing-dot {
|
|
111
|
+
animation: none;
|
|
112
|
+
opacity: 0.6;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('../../../types/data-display/chat').ChatProps} ChatProps
|
|
3
|
+
* @typedef {import('../../../types/data-display/chat').ChatMessage} ChatMessage
|
|
4
|
+
* @typedef {import('@inglorious/web').TemplateResult} TemplateResult
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { html } from "@inglorious/web"
|
|
8
|
+
import { ref } from "@inglorious/web/directives/ref"
|
|
9
|
+
import { repeat } from "@inglorious/web/directives/repeat"
|
|
10
|
+
import { when } from "@inglorious/web/directives/when"
|
|
11
|
+
|
|
12
|
+
import { Button } from "../../controls/button/index.js"
|
|
13
|
+
import { Textarea } from "../../controls/textarea/index.js"
|
|
14
|
+
import { Alert } from "../../feedback/alert/index.js"
|
|
15
|
+
|
|
16
|
+
/** How close to the bottom, in pixels, still counts as reading the latest. */
|
|
17
|
+
const PINNED_DISTANCE = 40
|
|
18
|
+
const TYPING_DOTS = 3
|
|
19
|
+
|
|
20
|
+
export const Chat = {
|
|
21
|
+
/**
|
|
22
|
+
* Main entrypoint for the chat template. It delegates to `renderChat` so overrides can still reuse it.
|
|
23
|
+
* A chat shows a conversation, the assistant's replies as they come, and a box to write the next message.
|
|
24
|
+
* @param {ChatProps} props The chat props.
|
|
25
|
+
* @returns {TemplateResult} The rendered chat.
|
|
26
|
+
*/
|
|
27
|
+
render(props) {
|
|
28
|
+
return this.renderChat(props)
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Renders the conversation, any error, the suggestions while it is empty, and the composer.
|
|
33
|
+
* @param {ChatProps} props The chat props.
|
|
34
|
+
* @returns {TemplateResult} The rendered chat.
|
|
35
|
+
*/
|
|
36
|
+
renderChat(props) {
|
|
37
|
+
const {
|
|
38
|
+
messages = [],
|
|
39
|
+
isBusy = false,
|
|
40
|
+
error,
|
|
41
|
+
suggestions = [],
|
|
42
|
+
label = "Chat",
|
|
43
|
+
} = props
|
|
44
|
+
|
|
45
|
+
return html`<section class="iw-chat" aria-label=${label}>
|
|
46
|
+
<div
|
|
47
|
+
class="iw-chat-messages"
|
|
48
|
+
role="log"
|
|
49
|
+
aria-live="polite"
|
|
50
|
+
@scroll=${keepPinned}
|
|
51
|
+
${ref(followLatest)}
|
|
52
|
+
>
|
|
53
|
+
${repeat(
|
|
54
|
+
messages,
|
|
55
|
+
(message) => message.id,
|
|
56
|
+
(message) => this.renderMessage?.(props, message),
|
|
57
|
+
)}
|
|
58
|
+
${when(isBusy, () => this.renderTyping?.(props))}
|
|
59
|
+
</div>
|
|
60
|
+
${when(error, () => Alert.render({ severity: "error", description: error }))}
|
|
61
|
+
${when(!messages.length && suggestions.length, () =>
|
|
62
|
+
this.renderSuggestions?.(props),
|
|
63
|
+
)}
|
|
64
|
+
${this.renderComposer?.(props)}
|
|
65
|
+
</section>`
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Renders one message: the user's in a bubble, the assistant's across the chat.
|
|
70
|
+
* Override to show who wrote it, or when.
|
|
71
|
+
* @param {ChatProps} props The chat props.
|
|
72
|
+
* @param {ChatMessage} message The message.
|
|
73
|
+
* @returns {TemplateResult} The rendered message.
|
|
74
|
+
*/
|
|
75
|
+
renderMessage(props, message) {
|
|
76
|
+
const isUser = message.role === "user"
|
|
77
|
+
const author = isUser
|
|
78
|
+
? (props.userLabel ?? "You")
|
|
79
|
+
: (props.assistantLabel ?? "Assistant")
|
|
80
|
+
|
|
81
|
+
return html`<article
|
|
82
|
+
class="iw-chat-message iw-chat-message-${isUser ? "user" : "assistant"}"
|
|
83
|
+
aria-label=${author}
|
|
84
|
+
>
|
|
85
|
+
<div class="iw-chat-message-content">
|
|
86
|
+
${this.renderContent?.(props, message)}
|
|
87
|
+
</div>
|
|
88
|
+
</article>`
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Renders what a message says: its `content`, or what `renderEntity` draws for its `entity`.
|
|
93
|
+
* @param {ChatProps} props The chat props.
|
|
94
|
+
* @param {ChatMessage} message The message.
|
|
95
|
+
* @returns {unknown} The message's content.
|
|
96
|
+
*/
|
|
97
|
+
renderContent(props, message) {
|
|
98
|
+
return when(
|
|
99
|
+
message.entity && props.renderEntity,
|
|
100
|
+
() => props.renderEntity(message.entity),
|
|
101
|
+
() => html`<span class="iw-chat-text">${message.content}</span>`,
|
|
102
|
+
)
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Renders the sign that a reply is on its way.
|
|
107
|
+
* @param {ChatProps} props The chat props.
|
|
108
|
+
* @returns {TemplateResult} The rendered indicator.
|
|
109
|
+
*/
|
|
110
|
+
renderTyping(props) {
|
|
111
|
+
const author = props.assistantLabel ?? "Assistant"
|
|
112
|
+
return html`<div
|
|
113
|
+
class="iw-chat-typing"
|
|
114
|
+
role="status"
|
|
115
|
+
aria-label=${`${author} is replying`}
|
|
116
|
+
>
|
|
117
|
+
${Array.from(
|
|
118
|
+
{ length: TYPING_DOTS },
|
|
119
|
+
() => html`<span class="iw-chat-typing-dot"></span>`,
|
|
120
|
+
)}
|
|
121
|
+
</div>`
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Renders the messages the user may start with, while the chat is empty.
|
|
126
|
+
* @param {ChatProps} props The chat props.
|
|
127
|
+
* @returns {TemplateResult} The rendered suggestions.
|
|
128
|
+
*/
|
|
129
|
+
renderSuggestions(props) {
|
|
130
|
+
return html`<div class="iw-chat-suggestions">
|
|
131
|
+
${props.suggestions.map((suggestion) =>
|
|
132
|
+
Button.render({
|
|
133
|
+
variant: "outline",
|
|
134
|
+
size: "sm",
|
|
135
|
+
children: suggestion,
|
|
136
|
+
isDisabled: props.isBusy,
|
|
137
|
+
onClick: () => props.onSend?.(suggestion),
|
|
138
|
+
}),
|
|
139
|
+
)}
|
|
140
|
+
</div>`
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Renders the box to write a message in, which grows with it, and the button that sends it.
|
|
145
|
+
* Enter sends; Shift+Enter starts a new line.
|
|
146
|
+
* @param {ChatProps} props The chat props.
|
|
147
|
+
* @returns {TemplateResult} The rendered composer.
|
|
148
|
+
*/
|
|
149
|
+
renderComposer(props) {
|
|
150
|
+
const { draft = "", isBusy = false } = props
|
|
151
|
+
const send = () => props.onSend?.()
|
|
152
|
+
|
|
153
|
+
return html`<form
|
|
154
|
+
class="iw-chat-composer"
|
|
155
|
+
@submit=${(event) => {
|
|
156
|
+
event.preventDefault()
|
|
157
|
+
send()
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
${Textarea.render({
|
|
161
|
+
name: "message",
|
|
162
|
+
ariaLabel: props.placeholder ?? "Message",
|
|
163
|
+
placeholder: props.placeholder ?? "Message",
|
|
164
|
+
value: draft,
|
|
165
|
+
rows: 1,
|
|
166
|
+
isAutoSize: true,
|
|
167
|
+
isFullWidth: true,
|
|
168
|
+
onChange: (value) => props.onDraftChange?.(value),
|
|
169
|
+
onKeyDown: (event) => {
|
|
170
|
+
// Enter sends, unless it closes a character being composed.
|
|
171
|
+
if (event.key !== "Enter" || event.shiftKey || event.isComposing) {
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
event.preventDefault()
|
|
175
|
+
send()
|
|
176
|
+
},
|
|
177
|
+
})}
|
|
178
|
+
${Button.render({
|
|
179
|
+
buttonType: "submit",
|
|
180
|
+
color: "primary",
|
|
181
|
+
children: props.sendLabel ?? "Send",
|
|
182
|
+
isDisabled: isBusy || !draft.trim(),
|
|
183
|
+
})}
|
|
184
|
+
</form>`
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Notes whether the user is reading the latest messages, at the bottom, or
|
|
190
|
+
* has scrolled up to read earlier ones.
|
|
191
|
+
* @param {Event} event
|
|
192
|
+
*/
|
|
193
|
+
function keepPinned(event) {
|
|
194
|
+
const log = event.currentTarget
|
|
195
|
+
const distance = log.scrollHeight - log.scrollTop - log.clientHeight
|
|
196
|
+
log.dataset.isPinned = String(distance < PINNED_DISTANCE)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* After each render, scrolls to the latest message, unless the user has
|
|
201
|
+
* scrolled up to read.
|
|
202
|
+
* @param {Element | undefined} log
|
|
203
|
+
*/
|
|
204
|
+
function followLatest(log) {
|
|
205
|
+
if (!log) return
|
|
206
|
+
queueMicrotask(() => {
|
|
207
|
+
if (log.dataset.isPinned !== "false") log.scrollTop = log.scrollHeight
|
|
208
|
+
})
|
|
209
|
+
}
|
|
@@ -35,9 +35,16 @@
|
|
|
35
35
|
gap: var(--iw-space-2);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
/* The two ends of a range share the column; native date and number
|
|
39
|
+
fields would otherwise keep a width of their own and spill out. */
|
|
40
|
+
.iw-data-grid-filter-range > .iw-input {
|
|
40
41
|
flex: 1 1 0;
|
|
42
|
+
min-width: 0;
|
|
43
|
+
|
|
44
|
+
.iw-input-frame,
|
|
45
|
+
.iw-input-control {
|
|
46
|
+
min-width: 0;
|
|
47
|
+
}
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
.iw-data-grid-row {
|
|
@@ -19,7 +19,6 @@ export const RangeFilter = {
|
|
|
19
19
|
placeholder: column.filter.placeholder ?? "≥",
|
|
20
20
|
size: "sm",
|
|
21
21
|
isFullWidth: true,
|
|
22
|
-
className: "iw-data-grid-filter-range-input",
|
|
23
22
|
onChange: (value) => {
|
|
24
23
|
const formattedValue = value ? format(value, column.type) : null
|
|
25
24
|
entity.onFilterChange?.({
|
|
@@ -34,7 +33,6 @@ export const RangeFilter = {
|
|
|
34
33
|
placeholder: column.filter.placeholder ?? "≤",
|
|
35
34
|
size: "sm",
|
|
36
35
|
isFullWidth: true,
|
|
37
|
-
className: "iw-data-grid-filter-range-input",
|
|
38
36
|
onChange: (value) => {
|
|
39
37
|
const formattedValue = value ? format(value, column.type) : null
|
|
40
38
|
entity.onFilterChange?.({
|
|
@@ -302,7 +302,7 @@ export function cellChange(entity, { rowId, columnId, value }) {
|
|
|
302
302
|
|
|
303
303
|
entity.edits ??= {}
|
|
304
304
|
const edits = (entity.edits[rowId] ??= {})
|
|
305
|
-
if (
|
|
305
|
+
if (isSameValue(row[columnId], value)) {
|
|
306
306
|
delete edits[columnId]
|
|
307
307
|
} else {
|
|
308
308
|
edits[columnId] = value
|
|
@@ -500,7 +500,7 @@ function getDefaultColumnFilter(type) {
|
|
|
500
500
|
*/
|
|
501
501
|
function getDefaultColumnWidth(filterType) {
|
|
502
502
|
if (filterType === "number") return 70
|
|
503
|
-
if (filterType === "range") return
|
|
503
|
+
if (filterType === "range") return 160
|
|
504
504
|
if (filterType === "select") return 70
|
|
505
505
|
if (filterType === "date") return 120
|
|
506
506
|
if (filterType === "time") return 120
|
|
@@ -564,3 +564,14 @@ function assertRowKeys(entity) {
|
|
|
564
564
|
seen.add(rowKey)
|
|
565
565
|
})
|
|
566
566
|
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Whether two cell values are the same, dates by their time.
|
|
570
|
+
* @param {any} a
|
|
571
|
+
* @param {any} b
|
|
572
|
+
* @returns {boolean}
|
|
573
|
+
*/
|
|
574
|
+
function isSameValue(a, b) {
|
|
575
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
|
|
576
|
+
return Object.is(a, b)
|
|
577
|
+
}
|
|
@@ -33,6 +33,13 @@ const PRETTY_PAGE = 1
|
|
|
33
33
|
const FIRST_PAGE = 0
|
|
34
34
|
const LAST_PAGE = 1
|
|
35
35
|
const ONE_EDIT = 1
|
|
36
|
+
const WEB_URL = /^https?:\/\//i
|
|
37
|
+
/** Fields edit dates as `YYYY-MM-DD`. */
|
|
38
|
+
const DATE_LENGTH = 10
|
|
39
|
+
const DATE_PART_LENGTH = 2
|
|
40
|
+
/** Date's months count from zero, people's from one. */
|
|
41
|
+
const MONTH_OFFSET = 1
|
|
42
|
+
const INPUT_TYPES = { number: "number", date: "date", url: "url" }
|
|
36
43
|
|
|
37
44
|
export const DataGrid = {
|
|
38
45
|
/**
|
|
@@ -317,15 +324,14 @@ export const DataGrid = {
|
|
|
317
324
|
})
|
|
318
325
|
}
|
|
319
326
|
|
|
320
|
-
const
|
|
327
|
+
const saved = row[column.id]
|
|
321
328
|
return Input.render({
|
|
322
329
|
size: "sm",
|
|
323
330
|
ariaLabel: column.title,
|
|
324
|
-
inputType:
|
|
325
|
-
value:
|
|
331
|
+
inputType: INPUT_TYPES[column.type] ?? "text",
|
|
332
|
+
value: column.type === "date" ? toDateField(value) : (value ?? ""),
|
|
326
333
|
isFullWidth: true,
|
|
327
|
-
onChange: (text) =>
|
|
328
|
-
change(isNumber ? (text === "" ? null : Number(text)) : text),
|
|
334
|
+
onChange: (text) => change(fromField(text, column.type, saved)),
|
|
329
335
|
})
|
|
330
336
|
},
|
|
331
337
|
|
|
@@ -337,8 +343,16 @@ export const DataGrid = {
|
|
|
337
343
|
* @param {any} payload.value The value to render.
|
|
338
344
|
* @returns {any} The value to be rendered.
|
|
339
345
|
*/
|
|
340
|
-
renderValue(_props, { value }) {
|
|
341
|
-
|
|
346
|
+
renderValue(_props, { value, column }) {
|
|
347
|
+
// A link only for a web address: a `javascript:` one must not run.
|
|
348
|
+
return when(
|
|
349
|
+
column.type === "url" && WEB_URL.test(String(value ?? "")),
|
|
350
|
+
() =>
|
|
351
|
+
html`<a href=${value} target="_blank" rel="noopener noreferrer"
|
|
352
|
+
>${value}</a
|
|
353
|
+
>`,
|
|
354
|
+
() => value,
|
|
355
|
+
)
|
|
342
356
|
},
|
|
343
357
|
|
|
344
358
|
/**
|
|
@@ -581,3 +595,38 @@ function getVirtualizationState(props, totalItems) {
|
|
|
581
595
|
visibleRange: { start, end },
|
|
582
596
|
}
|
|
583
597
|
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* A date, in whatever form a row keeps it, as a date field shows it.
|
|
601
|
+
* @param {Date|number|string|null|undefined} value
|
|
602
|
+
* @returns {string}
|
|
603
|
+
*/
|
|
604
|
+
function toDateField(value) {
|
|
605
|
+
if (value == null || value === "") return ""
|
|
606
|
+
if (typeof value === "string") return value.slice(0, DATE_LENGTH)
|
|
607
|
+
const date = new Date(value)
|
|
608
|
+
if (Number.isNaN(date.getTime())) return ""
|
|
609
|
+
const pad = (part) => String(part).padStart(DATE_PART_LENGTH, "0")
|
|
610
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + MONTH_OFFSET)}-${pad(date.getDate())}`
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* What a field holds, back in the form the row kept the value in: a number,
|
|
615
|
+
* or a date as a `Date`, a timestamp or a string, as it was. A date that had
|
|
616
|
+
* a time of day keeps it.
|
|
617
|
+
* @param {string} text
|
|
618
|
+
* @param {string} type The column's type.
|
|
619
|
+
* @param {any} saved The row's own value.
|
|
620
|
+
* @returns {any}
|
|
621
|
+
*/
|
|
622
|
+
function fromField(text, type, saved) {
|
|
623
|
+
if (type === "number") return text === "" ? null : Number(text)
|
|
624
|
+
if (type !== "date") return text
|
|
625
|
+
if (text === "") return null
|
|
626
|
+
if (!(saved instanceof Date) && typeof saved !== "number") return text
|
|
627
|
+
|
|
628
|
+
const [year, month, day] = text.split("-").map(Number)
|
|
629
|
+
const date = new Date(saved)
|
|
630
|
+
date.setFullYear(year, month - MONTH_OFFSET, day)
|
|
631
|
+
return saved instanceof Date ? date : date.getTime()
|
|
632
|
+
}
|
package/src/index.js
CHANGED
|
@@ -20,6 +20,7 @@ export { Avatar } from "./data-display/avatar/index.js"
|
|
|
20
20
|
export { Badge } from "./data-display/badge/index.js"
|
|
21
21
|
export { BeforeAfter } from "./data-display/before-after/index.js"
|
|
22
22
|
export { Carousel } from "./data-display/carousel/index.js"
|
|
23
|
+
export { Chat } from "./data-display/chat/index.js"
|
|
23
24
|
export { Chip } from "./data-display/chip/index.js"
|
|
24
25
|
export { DataGrid } from "./data-display/data-grid/index.js"
|
|
25
26
|
export { Divider } from "./data-display/divider/index.js"
|
|
@@ -6,6 +6,8 @@ export interface TextareaProps {
|
|
|
6
6
|
value?: string
|
|
7
7
|
placeholder?: string
|
|
8
8
|
label?: string
|
|
9
|
+
/** Names the field for assistive tech when it has no visible `label`. */
|
|
10
|
+
ariaLabel?: string
|
|
9
11
|
hint?: string
|
|
10
12
|
error?: string
|
|
11
13
|
size?: "sm" | "md" | "lg"
|
|
@@ -19,6 +21,8 @@ export interface TextareaProps {
|
|
|
19
21
|
onChange?: (value: string) => void
|
|
20
22
|
onBlur?: () => void
|
|
21
23
|
onFocus?: () => void
|
|
24
|
+
/** Called on every key press, such as to send a message on Enter. */
|
|
25
|
+
onKeyDown?: (event: KeyboardEvent) => void
|
|
22
26
|
[key: string]: unknown
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Api, TemplateResult } from "@inglorious/web"
|
|
2
|
+
|
|
3
|
+
/** One message of a chat: its text, or an entity that renders it. */
|
|
4
|
+
export interface ChatMessage {
|
|
5
|
+
id: string
|
|
6
|
+
role: "user" | "assistant"
|
|
7
|
+
/** What the message says, as text. */
|
|
8
|
+
content?: string
|
|
9
|
+
/** The id of an entity to render as the message, such as a generated answer. */
|
|
10
|
+
entity?: string
|
|
11
|
+
[key: string]: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** What a chat keeps and shows. */
|
|
15
|
+
export interface ChatProps {
|
|
16
|
+
id?: string
|
|
17
|
+
type?: string
|
|
18
|
+
messages?: ChatMessage[]
|
|
19
|
+
/** What the user is typing. */
|
|
20
|
+
draft?: string
|
|
21
|
+
/** A reply is on its way: the chat shows it, and does not send meanwhile. */
|
|
22
|
+
isBusy?: boolean
|
|
23
|
+
/** Why the last reply failed. */
|
|
24
|
+
error?: string | null
|
|
25
|
+
/** Messages to start with, offered while the chat is empty. */
|
|
26
|
+
suggestions?: string[]
|
|
27
|
+
/** Names the chat for assistive tech. Defaults to "Chat". */
|
|
28
|
+
label?: string
|
|
29
|
+
/** The message box's placeholder and name. Defaults to "Message". */
|
|
30
|
+
placeholder?: string
|
|
31
|
+
/** Defaults to "Send". */
|
|
32
|
+
sendLabel?: string
|
|
33
|
+
/** Who the user's messages are by, for assistive tech. Defaults to "You". */
|
|
34
|
+
userLabel?: string
|
|
35
|
+
/** Who replies. Defaults to "Assistant". */
|
|
36
|
+
assistantLabel?: string
|
|
37
|
+
onDraftChange?: (draft: string) => void
|
|
38
|
+
/** Sends `text`, such as a suggestion picked, or else the draft. */
|
|
39
|
+
onSend?: (text?: string) => void
|
|
40
|
+
/** Draws a message's `entity`. */
|
|
41
|
+
renderEntity?: (entityId: string) => unknown
|
|
42
|
+
[key: string]: unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A chat entity: its props, and a count that keeps message ids unique. */
|
|
46
|
+
export interface ChatEntity extends ChatProps {
|
|
47
|
+
id?: string
|
|
48
|
+
type: string
|
|
49
|
+
messages: ChatMessage[]
|
|
50
|
+
messageCount?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ChatType = {
|
|
54
|
+
create(entity: ChatEntity): void
|
|
55
|
+
draftChange(entity: ChatEntity, draft: string): void
|
|
56
|
+
/** Adds the user's message, `text` or the draft; apps answer by handling it too. */
|
|
57
|
+
messageSend(entity: ChatEntity, text?: string): void
|
|
58
|
+
/** Adds a message, the assistant's by default. */
|
|
59
|
+
messageAdd(entity: ChatEntity, message: Partial<ChatMessage>): void
|
|
60
|
+
replyStart(entity: ChatEntity): void
|
|
61
|
+
replyEnd(entity: ChatEntity): void
|
|
62
|
+
replyFail(entity: ChatEntity, error: string): void
|
|
63
|
+
messagesClear(entity: ChatEntity): void
|
|
64
|
+
render(entity: ChatEntity, api: Api): TemplateResult
|
|
65
|
+
renderChat(props: ChatProps): TemplateResult
|
|
66
|
+
renderMessage(props: ChatProps, message: ChatMessage): TemplateResult
|
|
67
|
+
renderContent(props: ChatProps, message: ChatMessage): unknown
|
|
68
|
+
renderTyping(props: ChatProps): TemplateResult
|
|
69
|
+
renderSuggestions(props: ChatProps): TemplateResult
|
|
70
|
+
renderComposer(props: ChatProps): TemplateResult
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export declare const Chat: ChatType
|
|
@@ -26,7 +26,11 @@ export interface DataGridVirtualization {
|
|
|
26
26
|
export interface Column {
|
|
27
27
|
id: string
|
|
28
28
|
title: string
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* `"url"` cells show web addresses as links. A date column's editor gives
|
|
31
|
+
* back dates in the form the row keeps them: `Date`, timestamp or string.
|
|
32
|
+
*/
|
|
33
|
+
type?: "string" | "number" | "boolean" | "date" | "url"
|
|
30
34
|
isSortable?: boolean
|
|
31
35
|
isFilterable?: boolean
|
|
32
36
|
filter?: {
|
package/types/index.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { Avatar } from "./data-display/avatar"
|
|
|
16
16
|
export { Badge } from "./data-display/badge"
|
|
17
17
|
export { BeforeAfter } from "./data-display/before-after"
|
|
18
18
|
export { Carousel } from "./data-display/carousel"
|
|
19
|
+
export { Chat } from "./data-display/chat"
|
|
19
20
|
export { Chip } from "./data-display/chip"
|
|
20
21
|
export { DataGrid } from "./data-display/data-grid"
|
|
21
22
|
export { Divider } from "./data-display/divider"
|