@happyvertical/smrt-ui 0.34.6 → 0.34.7
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/components/chat/MessageBubble.svelte +123 -33
- package/dist/components/chat/MessageBubble.svelte.d.ts +33 -10
- package/dist/components/chat/MessageBubble.svelte.d.ts.map +1 -1
- package/dist/components/chat/ReactionPicker.svelte +50 -18
- package/dist/components/chat/ReactionPicker.svelte.d.ts +8 -8
- package/dist/components/chat/ReactionPicker.svelte.d.ts.map +1 -1
- package/dist/components/chat/TypingIndicator.svelte +42 -25
- package/dist/components/chat/TypingIndicator.svelte.d.ts +12 -5
- package/dist/components/chat/TypingIndicator.svelte.d.ts.map +1 -1
- package/dist/components/chat/__tests__/chat-primitives.test.js +52 -1
- package/dist/components/forms/Form.svelte +7 -5
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/Input.svelte +6 -0
- package/dist/components/forms/Select.svelte +6 -0
- package/dist/components/forms/Textarea.svelte +6 -0
- package/dist/components/forms/Toggle.svelte +7 -0
- package/dist/i18n/strings.ui.d.ts +2 -0
- package/dist/i18n/strings.ui.d.ts.map +1 -1
- package/dist/i18n/strings.ui.js +3 -0
- package/package.json +2 -2
|
@@ -1,26 +1,50 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* MessageBubble — a single chat message
|
|
3
|
+
* MessageBubble — a single chat message, tokenised and a11y-clean.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Two complementary forms share one styled container:
|
|
6
|
+
*
|
|
7
|
+
* - **Bare bubble** (the common case for a message list that renders its own
|
|
8
|
+
* author/time/reactions around each row): opt into the styling axes by passing
|
|
9
|
+
* `variant` and/or `own` (plus `content` or a `children` snippet). No header or
|
|
10
|
+
* labelled group is rendered — unless you also pass an `author` — so it nests
|
|
11
|
+
* cleanly inside an already-labelled message row without a redundant landmark.
|
|
12
|
+
* - **Self-contained card** (legacy form): pass `role` / `author` / `timestamp`
|
|
13
|
+
* (and optionally the `reactions` / `actions` snippets) without the styling
|
|
14
|
+
* axes. A header and a labelled `role="group"` (`<author|role> (<role>)`) are
|
|
15
|
+
* rendered so assistive tech can announce who sent each message; `author`
|
|
16
|
+
* falls back to a role label ("You" / "Assistant" / "System").
|
|
17
|
+
*
|
|
18
|
+
* `variant` + `own` are the canonical styling axes. The legacy `role` prop sets
|
|
19
|
+
* the header's role label and derives `variant`/`own` when those are not given.
|
|
9
20
|
*/
|
|
10
21
|
import type { Snippet } from 'svelte';
|
|
11
22
|
import type { ChatMessageRole } from '../../types-generic';
|
|
12
23
|
|
|
24
|
+
/** Visual tone: a peer message, an assistant message, or a centered notice. */
|
|
25
|
+
export type MessageBubbleVariant = 'default' | 'agent' | 'system';
|
|
26
|
+
|
|
13
27
|
export interface Props {
|
|
14
|
-
/**
|
|
28
|
+
/** Visual tone. Canonical styling axis. */
|
|
29
|
+
variant?: MessageBubbleVariant;
|
|
30
|
+
/** Whether the current viewer authored this message — drives alignment + own-color. */
|
|
31
|
+
own?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Legacy role. Prefer `variant` + `own`. Sets the header's role label and,
|
|
34
|
+
* when `variant`/`own` are unset, derives them (user → own default, agent →
|
|
35
|
+
* agent, system → system).
|
|
36
|
+
*/
|
|
15
37
|
role?: ChatMessageRole;
|
|
16
|
-
/**
|
|
38
|
+
/** Plain-text body. Ignored when a `children` snippet is provided. */
|
|
39
|
+
content?: string;
|
|
40
|
+
/** Body snippet (takes precedence over `content`). */
|
|
41
|
+
children?: Snippet;
|
|
42
|
+
/** Display name of the sender. When set, a header + labelled group render. */
|
|
17
43
|
author?: string;
|
|
18
|
-
/** Message time; rendered in a `<time datetime>` element. */
|
|
44
|
+
/** Message time; rendered in a `<time datetime>` element in the header. */
|
|
19
45
|
timestamp?: Date;
|
|
20
46
|
/** Override the visible timestamp text (defaults to a locale time). */
|
|
21
47
|
timestampLabel?: string;
|
|
22
|
-
/** Message body. */
|
|
23
|
-
children: Snippet;
|
|
24
48
|
/** Optional reactions row (e.g. a `ReactionPicker` or reaction chips). */
|
|
25
49
|
reactions?: Snippet;
|
|
26
50
|
/** Optional per-message actions (reply, copy, …). */
|
|
@@ -28,11 +52,14 @@ export interface Props {
|
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
const {
|
|
55
|
+
variant,
|
|
56
|
+
own,
|
|
31
57
|
role = 'user',
|
|
58
|
+
content,
|
|
59
|
+
children,
|
|
32
60
|
author,
|
|
33
61
|
timestamp,
|
|
34
62
|
timestampLabel,
|
|
35
|
-
children,
|
|
36
63
|
reactions,
|
|
37
64
|
actions,
|
|
38
65
|
}: Props = $props();
|
|
@@ -43,23 +70,50 @@ const roleNoun: Record<ChatMessageRole, string> = {
|
|
|
43
70
|
system: 'System',
|
|
44
71
|
};
|
|
45
72
|
|
|
46
|
-
|
|
73
|
+
// `variant`/`own` are canonical; derive them from the legacy `role` only when
|
|
74
|
+
// they are not explicitly supplied.
|
|
75
|
+
const effectiveVariant = $derived<MessageBubbleVariant>(
|
|
76
|
+
variant ??
|
|
77
|
+
(role === 'agent' ? 'agent' : role === 'system' ? 'system' : 'default'),
|
|
78
|
+
);
|
|
79
|
+
const effectiveOwn = $derived(own ?? role === 'user');
|
|
80
|
+
|
|
81
|
+
// The bare-bubble form is signalled by opting into the explicit styling axes
|
|
82
|
+
// (`variant`/`own`); the legacy card form (`role`/`author`/`timestamp`) is not.
|
|
83
|
+
const isBareForm = $derived(variant !== undefined || own !== undefined);
|
|
84
|
+
const headerName = $derived(author ?? roleNoun[role]);
|
|
85
|
+
// Render a header + labelled group for the legacy card form, or whenever an
|
|
86
|
+
// author is supplied. A bare bubble with no author stays a plain styled
|
|
87
|
+
// container (its host row owns the label).
|
|
88
|
+
const hasHeader = $derived(author !== undefined || !isBareForm);
|
|
89
|
+
const groupLabel = $derived(`${headerName} (${role})`);
|
|
47
90
|
const isoTime = $derived(timestamp ? timestamp.toISOString() : undefined);
|
|
48
91
|
const timeText = $derived(
|
|
49
92
|
timestampLabel ?? timestamp?.toLocaleTimeString() ?? '',
|
|
50
93
|
);
|
|
51
94
|
</script>
|
|
52
95
|
|
|
53
|
-
<div
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
96
|
+
<div
|
|
97
|
+
class="bubble bubble--{effectiveVariant}"
|
|
98
|
+
class:bubble--own={effectiveOwn}
|
|
99
|
+
role={hasHeader ? 'group' : undefined}
|
|
100
|
+
aria-label={hasHeader ? groupLabel : undefined}
|
|
101
|
+
>
|
|
102
|
+
{#if hasHeader}
|
|
103
|
+
<div class="bubble__header">
|
|
104
|
+
<span class="bubble__author">{headerName}</span>
|
|
105
|
+
{#if timeText}
|
|
106
|
+
<time class="bubble__time" datetime={isoTime}>{timeText}</time>
|
|
107
|
+
{/if}
|
|
108
|
+
</div>
|
|
109
|
+
{/if}
|
|
60
110
|
|
|
61
111
|
<div class="bubble__body">
|
|
62
|
-
{
|
|
112
|
+
{#if children}
|
|
113
|
+
{@render children()}
|
|
114
|
+
{:else if content !== undefined}
|
|
115
|
+
<p class="bubble__content">{content}</p>
|
|
116
|
+
{/if}
|
|
63
117
|
</div>
|
|
64
118
|
|
|
65
119
|
{#if reactions}
|
|
@@ -76,23 +130,57 @@ const timeText = $derived(
|
|
|
76
130
|
display: flex;
|
|
77
131
|
flex-direction: column;
|
|
78
132
|
gap: var(--smrt-spacing-1, 4px);
|
|
79
|
-
max-width:
|
|
80
|
-
padding: var(--smrt-spacing-
|
|
133
|
+
max-width: 75%;
|
|
134
|
+
padding: var(--smrt-spacing-3, 12px) var(--smrt-spacing-4, 16px);
|
|
81
135
|
border-radius: var(--smrt-radius-large, 12px);
|
|
82
|
-
|
|
83
|
-
|
|
136
|
+
font-size: var(--smrt-typography-body-medium-size, 0.875rem);
|
|
137
|
+
line-height: 1.4;
|
|
138
|
+
word-break: break-word;
|
|
84
139
|
}
|
|
85
140
|
|
|
86
|
-
.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
141
|
+
/* default — a peer message; `own` is the current viewer's message. */
|
|
142
|
+
.bubble--default {
|
|
143
|
+
background: var(--smrt-color-surface-container, #f3f4f6);
|
|
144
|
+
color: var(--smrt-color-on-surface, #1b1b1f);
|
|
145
|
+
border-bottom-left-radius: var(--smrt-radius-small, 4px);
|
|
146
|
+
}
|
|
147
|
+
.bubble--default.bubble--own {
|
|
148
|
+
background: var(--smrt-color-primary, #005ac1);
|
|
149
|
+
color: var(--smrt-color-on-primary, #ffffff);
|
|
150
|
+
border-bottom-right-radius: var(--smrt-radius-small, 4px);
|
|
151
|
+
border-bottom-left-radius: var(--smrt-radius-large, 12px);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* agent — assistant message, accented on the leading edge. */
|
|
155
|
+
.bubble--agent {
|
|
156
|
+
background: var(--smrt-color-tertiary-container, #ffd8e4);
|
|
157
|
+
color: var(--smrt-color-on-tertiary-container, #31111d);
|
|
158
|
+
border-bottom-left-radius: var(--smrt-radius-small, 4px);
|
|
159
|
+
border-left: 3px solid var(--smrt-color-tertiary, #7d5260);
|
|
160
|
+
}
|
|
161
|
+
.bubble--agent.bubble--own {
|
|
162
|
+
border-left: none;
|
|
163
|
+
border-right: 3px solid var(--smrt-color-tertiary, #7d5260);
|
|
164
|
+
border-bottom-right-radius: var(--smrt-radius-small, 4px);
|
|
165
|
+
border-bottom-left-radius: var(--smrt-radius-large, 12px);
|
|
90
166
|
}
|
|
167
|
+
|
|
168
|
+
/* system — centered notice. */
|
|
91
169
|
.bubble--system {
|
|
92
|
-
|
|
93
|
-
|
|
170
|
+
max-width: 100%;
|
|
171
|
+
background: transparent;
|
|
172
|
+
color: var(--smrt-color-on-surface-variant, #43474e);
|
|
173
|
+
text-align: center;
|
|
174
|
+
font-size: var(--smrt-typography-body-small-size, 0.75rem);
|
|
175
|
+
padding: var(--smrt-spacing-2, 6px) var(--smrt-spacing-4, 16px);
|
|
176
|
+
border-radius: 0;
|
|
94
177
|
align-self: center;
|
|
95
|
-
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* Self-align own messages when laid out in a column (card form). In a host
|
|
181
|
+
row that already aligns own messages this is a harmless no-op. */
|
|
182
|
+
.bubble--own {
|
|
183
|
+
align-self: flex-end;
|
|
96
184
|
}
|
|
97
185
|
|
|
98
186
|
.bubble__header {
|
|
@@ -111,11 +199,13 @@ const timeText = $derived(
|
|
|
111
199
|
}
|
|
112
200
|
|
|
113
201
|
.bubble__body {
|
|
114
|
-
font-size: var(--smrt-typography-body-medium-size, 1rem);
|
|
115
|
-
line-height: var(--smrt-typography-body-medium-line-height, 1.5);
|
|
116
202
|
white-space: pre-wrap;
|
|
117
203
|
word-break: break-word;
|
|
118
204
|
}
|
|
205
|
+
.bubble__content {
|
|
206
|
+
margin: 0;
|
|
207
|
+
white-space: pre-wrap;
|
|
208
|
+
}
|
|
119
209
|
|
|
120
210
|
.bubble__reactions,
|
|
121
211
|
.bubble__actions {
|
|
@@ -1,24 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MessageBubble — a single chat message
|
|
2
|
+
* MessageBubble — a single chat message, tokenised and a11y-clean.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Two complementary forms share one styled container:
|
|
5
|
+
*
|
|
6
|
+
* - **Bare bubble** (the common case for a message list that renders its own
|
|
7
|
+
* author/time/reactions around each row): opt into the styling axes by passing
|
|
8
|
+
* `variant` and/or `own` (plus `content` or a `children` snippet). No header or
|
|
9
|
+
* labelled group is rendered — unless you also pass an `author` — so it nests
|
|
10
|
+
* cleanly inside an already-labelled message row without a redundant landmark.
|
|
11
|
+
* - **Self-contained card** (legacy form): pass `role` / `author` / `timestamp`
|
|
12
|
+
* (and optionally the `reactions` / `actions` snippets) without the styling
|
|
13
|
+
* axes. A header and a labelled `role="group"` (`<author|role> (<role>)`) are
|
|
14
|
+
* rendered so assistive tech can announce who sent each message; `author`
|
|
15
|
+
* falls back to a role label ("You" / "Assistant" / "System").
|
|
16
|
+
*
|
|
17
|
+
* `variant` + `own` are the canonical styling axes. The legacy `role` prop sets
|
|
18
|
+
* the header's role label and derives `variant`/`own` when those are not given.
|
|
8
19
|
*/
|
|
9
20
|
import type { Snippet } from 'svelte';
|
|
10
21
|
import type { ChatMessageRole } from '../../types-generic';
|
|
22
|
+
/** Visual tone: a peer message, an assistant message, or a centered notice. */
|
|
23
|
+
export type MessageBubbleVariant = 'default' | 'agent' | 'system';
|
|
11
24
|
export interface Props {
|
|
12
|
-
/**
|
|
25
|
+
/** Visual tone. Canonical styling axis. */
|
|
26
|
+
variant?: MessageBubbleVariant;
|
|
27
|
+
/** Whether the current viewer authored this message — drives alignment + own-color. */
|
|
28
|
+
own?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Legacy role. Prefer `variant` + `own`. Sets the header's role label and,
|
|
31
|
+
* when `variant`/`own` are unset, derives them (user → own default, agent →
|
|
32
|
+
* agent, system → system).
|
|
33
|
+
*/
|
|
13
34
|
role?: ChatMessageRole;
|
|
14
|
-
/**
|
|
35
|
+
/** Plain-text body. Ignored when a `children` snippet is provided. */
|
|
36
|
+
content?: string;
|
|
37
|
+
/** Body snippet (takes precedence over `content`). */
|
|
38
|
+
children?: Snippet;
|
|
39
|
+
/** Display name of the sender. When set, a header + labelled group render. */
|
|
15
40
|
author?: string;
|
|
16
|
-
/** Message time; rendered in a `<time datetime>` element. */
|
|
41
|
+
/** Message time; rendered in a `<time datetime>` element in the header. */
|
|
17
42
|
timestamp?: Date;
|
|
18
43
|
/** Override the visible timestamp text (defaults to a locale time). */
|
|
19
44
|
timestampLabel?: string;
|
|
20
|
-
/** Message body. */
|
|
21
|
-
children: Snippet;
|
|
22
45
|
/** Optional reactions row (e.g. a `ReactionPicker` or reaction chips). */
|
|
23
46
|
reactions?: Snippet;
|
|
24
47
|
/** Optional per-message actions (reply, copy, …). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MessageBubble.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/chat/MessageBubble.svelte.ts"],"names":[],"mappings":"AAGA
|
|
1
|
+
{"version":3,"file":"MessageBubble.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/chat/MessageBubble.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAG3D,+EAA+E;AAC/E,MAAM,MAAM,oBAAoB,GAAG,SAAS,GAAG,OAAO,GAAG,QAAQ,CAAC;AAElE,MAAM,WAAW,KAAK;IACpB,2CAA2C;IAC3C,OAAO,CAAC,EAAE,oBAAoB,CAAC;IAC/B,uFAAuF;IACvF,GAAG,CAAC,EAAE,OAAO,CAAC;IACd;;;;OAIG;IACH,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,sEAAsE;IACtE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,uEAAuE;IACvE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,qDAAqD;IACrD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAiFD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -2,15 +2,29 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* ReactionPicker — a labelled group of emoji reaction buttons.
|
|
4
4
|
*
|
|
5
|
-
* Each button
|
|
6
|
-
* glyphs)
|
|
7
|
-
* activation.
|
|
5
|
+
* Each button carries a text accessible name (so the emoji isn't announced as
|
|
6
|
+
* raw glyphs) and the group carries an `aria-label`. Fires `onpick(emoji)` on
|
|
7
|
+
* activation. The group label and the fallback name for unrecognised emoji
|
|
8
|
+
* default to translated `ui.reaction_picker.*` strings; the common reactions use
|
|
9
|
+
* built-in English names unless a caller supplies `emojiLabel` (e.g. to route
|
|
10
|
+
* every label through a domain i18n catalog). Wraps gracefully for larger emoji
|
|
11
|
+
* sets.
|
|
8
12
|
*/
|
|
13
|
+
import { M } from '../../i18n/strings.ui.js';
|
|
14
|
+
import { useI18n } from '../../i18n/use-i18n.js';
|
|
15
|
+
|
|
9
16
|
export interface Props {
|
|
10
17
|
/** Emoji set to offer. */
|
|
11
18
|
emojis?: string[];
|
|
12
|
-
/** Accessible label for the group. */
|
|
19
|
+
/** Accessible label for the group. Defaults to a translated "Add reaction". */
|
|
13
20
|
label?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Accessible name for each emoji button. Defaults to a built-in name for the
|
|
23
|
+
* common reactions, else a translated "React with {emoji}".
|
|
24
|
+
*/
|
|
25
|
+
emojiLabel?: (emoji: string) => string;
|
|
26
|
+
/** Whether the picker is shown. */
|
|
27
|
+
isOpen?: boolean;
|
|
14
28
|
/** Fired with the chosen emoji. */
|
|
15
29
|
onpick?: (emoji: string) => void;
|
|
16
30
|
}
|
|
@@ -27,30 +41,48 @@ const EMOJI_LABELS: Record<string, string> = {
|
|
|
27
41
|
|
|
28
42
|
const {
|
|
29
43
|
emojis = DEFAULT_EMOJIS,
|
|
30
|
-
label
|
|
44
|
+
label,
|
|
45
|
+
emojiLabel,
|
|
46
|
+
isOpen = true,
|
|
31
47
|
onpick,
|
|
32
48
|
}: Props = $props();
|
|
49
|
+
|
|
50
|
+
const { t } = useI18n();
|
|
51
|
+
|
|
52
|
+
const groupLabel = $derived(label ?? t(M['ui.reaction_picker.label']));
|
|
53
|
+
|
|
54
|
+
function nameFor(emoji: string): string {
|
|
55
|
+
return (
|
|
56
|
+
emojiLabel?.(emoji) ??
|
|
57
|
+
EMOJI_LABELS[emoji] ??
|
|
58
|
+
t(M['ui.reaction_picker.react_with'], { emoji })
|
|
59
|
+
);
|
|
60
|
+
}
|
|
33
61
|
</script>
|
|
34
62
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
63
|
+
{#if isOpen}
|
|
64
|
+
<div class="reactions" role="group" aria-label={groupLabel}>
|
|
65
|
+
{#each emojis as emoji (emoji)}
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
class="reactions__btn"
|
|
69
|
+
aria-label={nameFor(emoji)}
|
|
70
|
+
onclick={() => onpick?.(emoji)}
|
|
71
|
+
>
|
|
72
|
+
<span aria-hidden="true">{emoji}</span>
|
|
73
|
+
</button>
|
|
74
|
+
{/each}
|
|
75
|
+
</div>
|
|
76
|
+
{/if}
|
|
47
77
|
|
|
48
78
|
<style>
|
|
49
79
|
.reactions {
|
|
50
80
|
display: inline-flex;
|
|
81
|
+
flex-wrap: wrap;
|
|
51
82
|
gap: var(--smrt-spacing-1, 4px);
|
|
52
83
|
padding: var(--smrt-spacing-1, 4px);
|
|
53
|
-
|
|
84
|
+
max-width: 16rem;
|
|
85
|
+
border-radius: var(--smrt-radius-large, 12px);
|
|
54
86
|
background: var(--smrt-color-surface-container, #f3edf7);
|
|
55
87
|
}
|
|
56
88
|
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ReactionPicker — a labelled group of emoji reaction buttons.
|
|
3
|
-
*
|
|
4
|
-
* Each button has a text accessible name (so the emoji isn't announced as raw
|
|
5
|
-
* glyphs), and the group carries an `aria-label`. Fires `onpick(emoji)` on
|
|
6
|
-
* activation.
|
|
7
|
-
*/
|
|
8
1
|
export interface Props {
|
|
9
2
|
/** Emoji set to offer. */
|
|
10
3
|
emojis?: string[];
|
|
11
|
-
/** Accessible label for the group. */
|
|
4
|
+
/** Accessible label for the group. Defaults to a translated "Add reaction". */
|
|
12
5
|
label?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Accessible name for each emoji button. Defaults to a built-in name for the
|
|
8
|
+
* common reactions, else a translated "React with {emoji}".
|
|
9
|
+
*/
|
|
10
|
+
emojiLabel?: (emoji: string) => string;
|
|
11
|
+
/** Whether the picker is shown. */
|
|
12
|
+
isOpen?: boolean;
|
|
13
13
|
/** Fired with the chosen emoji. */
|
|
14
14
|
onpick?: (emoji: string) => void;
|
|
15
15
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ReactionPicker.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ReactionPicker.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ReactionPicker.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ReactionPicker.svelte.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,KAAK;IACpB,0BAA0B;IAC1B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACvC,mCAAmC;IACnC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,mCAAmC;IACnC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC;AAoDD,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
|
|
@@ -2,51 +2,68 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* TypingIndicator — animated "…is typing" affordance.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Renders a visible label plus decorative animated dots (`aria-hidden`) inside a
|
|
6
|
+
* polite `role="status"` live region, so both sighted users and screen readers
|
|
7
|
+
* learn who is typing. Honors reduced-motion.
|
|
8
|
+
*
|
|
9
|
+
* Pass `names` to announce one or more typists ("Ada is typing", "Ada and Bob
|
|
10
|
+
* are typing", "Ada and 2 others are typing") — an empty list renders nothing.
|
|
11
|
+
* Or pass a single `name` / a full `label` override. The animated dots stand in
|
|
12
|
+
* for the trailing ellipsis, so the label text omits it.
|
|
8
13
|
*/
|
|
9
14
|
export interface Props {
|
|
10
|
-
/** Who is typing (
|
|
15
|
+
/** Who is typing (single). */
|
|
11
16
|
name?: string;
|
|
12
|
-
/**
|
|
17
|
+
/** Names of everyone currently typing — aggregated into the label. */
|
|
18
|
+
names?: string[];
|
|
19
|
+
/** Full label override; defaults to an aggregation of `names`/`name`. */
|
|
13
20
|
label?: string;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
|
-
const { name, label }: Props = $props();
|
|
17
|
-
|
|
23
|
+
const { name, names, label }: Props = $props();
|
|
24
|
+
|
|
25
|
+
// Names-mode (an explicit list) renders nothing when nobody is typing; the
|
|
26
|
+
// single-`name`/`label` form always renders.
|
|
27
|
+
const inNamesMode = $derived(names !== undefined);
|
|
28
|
+
const show = $derived(!inNamesMode || (names?.length ?? 0) > 0);
|
|
29
|
+
|
|
30
|
+
const text = $derived.by(() => {
|
|
31
|
+
if (label) return label;
|
|
32
|
+
if (names && names.length > 0) {
|
|
33
|
+
if (names.length === 1) return `${names[0]} is typing`;
|
|
34
|
+
if (names.length === 2) return `${names[0]} and ${names[1]} are typing`;
|
|
35
|
+
return `${names[0]} and ${names.length - 1} others are typing`;
|
|
36
|
+
}
|
|
37
|
+
return name ? `${name} is typing` : 'Typing';
|
|
38
|
+
});
|
|
18
39
|
</script>
|
|
19
40
|
|
|
20
|
-
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
<span class="
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
</
|
|
41
|
+
{#if show}
|
|
42
|
+
<div class="typing" role="status" aria-live="polite">
|
|
43
|
+
<span class="typing__text">{text}</span>
|
|
44
|
+
<span class="typing__dots" aria-hidden="true">
|
|
45
|
+
<span class="typing__dot"></span>
|
|
46
|
+
<span class="typing__dot"></span>
|
|
47
|
+
<span class="typing__dot"></span>
|
|
48
|
+
</span>
|
|
49
|
+
</div>
|
|
50
|
+
{/if}
|
|
28
51
|
|
|
29
52
|
<style>
|
|
30
53
|
.typing {
|
|
31
54
|
display: inline-flex;
|
|
32
55
|
align-items: center;
|
|
33
|
-
gap: var(--smrt-spacing-
|
|
56
|
+
gap: var(--smrt-spacing-2, 8px);
|
|
34
57
|
padding: var(--smrt-spacing-2, 8px) var(--smrt-spacing-3, 12px);
|
|
35
58
|
border-radius: var(--smrt-radius-large, 12px);
|
|
36
59
|
background: var(--smrt-color-surface-container, #f3edf7);
|
|
37
60
|
width: fit-content;
|
|
38
61
|
}
|
|
39
62
|
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
height: 1px;
|
|
44
|
-
padding: 0;
|
|
45
|
-
margin: -1px;
|
|
46
|
-
overflow: hidden;
|
|
47
|
-
clip: rect(0, 0, 0, 0);
|
|
63
|
+
.typing__text {
|
|
64
|
+
font-size: var(--smrt-typography-body-small-size, 0.75rem);
|
|
65
|
+
color: var(--smrt-color-on-surface-variant, #49454f);
|
|
48
66
|
white-space: nowrap;
|
|
49
|
-
border: 0;
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
.typing__dots {
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TypingIndicator — animated "…is typing" affordance.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Renders a visible label plus decorative animated dots (`aria-hidden`) inside a
|
|
5
|
+
* polite `role="status"` live region, so both sighted users and screen readers
|
|
6
|
+
* learn who is typing. Honors reduced-motion.
|
|
7
|
+
*
|
|
8
|
+
* Pass `names` to announce one or more typists ("Ada is typing", "Ada and Bob
|
|
9
|
+
* are typing", "Ada and 2 others are typing") — an empty list renders nothing.
|
|
10
|
+
* Or pass a single `name` / a full `label` override. The animated dots stand in
|
|
11
|
+
* for the trailing ellipsis, so the label text omits it.
|
|
7
12
|
*/
|
|
8
13
|
export interface Props {
|
|
9
|
-
/** Who is typing (
|
|
14
|
+
/** Who is typing (single). */
|
|
10
15
|
name?: string;
|
|
11
|
-
/**
|
|
16
|
+
/** Names of everyone currently typing — aggregated into the label. */
|
|
17
|
+
names?: string[];
|
|
18
|
+
/** Full label override; defaults to an aggregation of `names`/`name`. */
|
|
12
19
|
label?: string;
|
|
13
20
|
}
|
|
14
21
|
declare const TypingIndicator: import("svelte").Component<Props, {}, "">;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TypingIndicator.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/chat/TypingIndicator.svelte.ts"],"names":[],"mappings":"AAGA
|
|
1
|
+
{"version":3,"file":"TypingIndicator.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/chat/TypingIndicator.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,KAAK;IACpB,8BAA8B;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAqCD,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
|
|
@@ -29,12 +29,32 @@ describe('MessageBubble', () => {
|
|
|
29
29
|
});
|
|
30
30
|
expect(container.querySelector('time')).toHaveAttribute('datetime', '2026-01-01T10:00:00.000Z');
|
|
31
31
|
});
|
|
32
|
-
it('
|
|
32
|
+
it('renders a bare bubble (no header/group) from plain content', () => {
|
|
33
|
+
render(MessageBubble, {
|
|
34
|
+
props: { content: 'Hello there', variant: 'default', own: false },
|
|
35
|
+
});
|
|
36
|
+
expect(screen.getByText('Hello there')).toBeInTheDocument();
|
|
37
|
+
// A bare bubble adds no labelled landmark — its host row owns the label.
|
|
38
|
+
expect(screen.queryByRole('group')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
it('renders a legacy card header even without an author', () => {
|
|
41
|
+
// The legacy card form (role, no styling axes) keeps its header + labelled
|
|
42
|
+
// group, falling back to the role label.
|
|
43
|
+
render(MessageBubble, { props: { role: 'agent', children: body('hi') } });
|
|
44
|
+
expect(screen.getByRole('group', { name: 'Assistant (agent)' })).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
it('is axe-clean as a labelled card', async () => {
|
|
33
47
|
const { container } = render(MessageBubble, {
|
|
34
48
|
props: { role: 'user', author: 'You', children: body('hi') },
|
|
35
49
|
});
|
|
36
50
|
await expectNoA11yViolations(container);
|
|
37
51
|
});
|
|
52
|
+
it('is axe-clean as an own bare bubble', async () => {
|
|
53
|
+
const { container } = render(MessageBubble, {
|
|
54
|
+
props: { content: 'Hi', variant: 'default', own: true },
|
|
55
|
+
});
|
|
56
|
+
await expectNoA11yViolations(container);
|
|
57
|
+
});
|
|
38
58
|
});
|
|
39
59
|
describe('ReactionPicker', () => {
|
|
40
60
|
it('is a labelled group of named emoji buttons', () => {
|
|
@@ -48,6 +68,21 @@ describe('ReactionPicker', () => {
|
|
|
48
68
|
await userEvent.click(screen.getByRole('button', { name: 'Heart' }));
|
|
49
69
|
expect(onpick).toHaveBeenCalledWith('❤️');
|
|
50
70
|
});
|
|
71
|
+
it('renders nothing when closed', () => {
|
|
72
|
+
render(ReactionPicker, { props: { isOpen: false } });
|
|
73
|
+
expect(screen.queryByRole('group')).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
it('honors caller-supplied group + per-emoji labels', () => {
|
|
76
|
+
render(ReactionPicker, {
|
|
77
|
+
props: {
|
|
78
|
+
emojis: ['🚀'],
|
|
79
|
+
label: 'Emoji reactions',
|
|
80
|
+
emojiLabel: (emoji) => `React with ${emoji}`,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
expect(screen.getByRole('group', { name: 'Emoji reactions' })).toBeInTheDocument();
|
|
84
|
+
expect(screen.getByRole('button', { name: 'React with 🚀' })).toBeInTheDocument();
|
|
85
|
+
});
|
|
51
86
|
it('is axe-clean', async () => {
|
|
52
87
|
const { container } = render(ReactionPicker);
|
|
53
88
|
await expectNoA11yViolations(container);
|
|
@@ -58,6 +93,22 @@ describe('TypingIndicator', () => {
|
|
|
58
93
|
render(TypingIndicator, { props: { name: 'Assistant' } });
|
|
59
94
|
expect(screen.getByRole('status')).toHaveTextContent('Assistant is typing');
|
|
60
95
|
});
|
|
96
|
+
it('names a single typist from a list', () => {
|
|
97
|
+
render(TypingIndicator, { props: { names: ['Ada'] } });
|
|
98
|
+
expect(screen.getByText('Ada is typing')).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
it('names two typists from a list', () => {
|
|
101
|
+
render(TypingIndicator, { props: { names: ['Ada', 'Bob'] } });
|
|
102
|
+
expect(screen.getByText('Ada and Bob are typing')).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
it('aggregates three or more typists', () => {
|
|
105
|
+
render(TypingIndicator, { props: { names: ['Ada', 'Bob', 'Cy'] } });
|
|
106
|
+
expect(screen.getByText('Ada and 2 others are typing')).toBeInTheDocument();
|
|
107
|
+
});
|
|
108
|
+
it('renders nothing when nobody is typing', () => {
|
|
109
|
+
const { container } = render(TypingIndicator, { props: { names: [] } });
|
|
110
|
+
expect(container.querySelector('.typing')).toBeNull();
|
|
111
|
+
});
|
|
61
112
|
it('is axe-clean', async () => {
|
|
62
113
|
const { container } = render(TypingIndicator, {
|
|
63
114
|
props: { name: 'Assistant' },
|
|
@@ -44,8 +44,10 @@ function handleSubmit(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
|
|
|
44
44
|
{@render children()}
|
|
45
45
|
</form>
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
<!--
|
|
48
|
+
No base styles: a <form> is `display: block` by default, so an explicit
|
|
49
|
+
`.form { display: block }` rule would only add a specificity floor that ties
|
|
50
|
+
with a consumer's single-class layout override (e.g. `:global(.x){display:flex}`)
|
|
51
|
+
and can win by stylesheet order. The `form` class stays as a stable hook.
|
|
52
|
+
-->
|
|
53
|
+
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Form.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Form.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAG1D,MAAM,WAAW,KAAM,SAAQ,IAAI,CAAC,kBAAkB,EAAE,OAAO,CAAC;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,QAAQ,EAAE,OAAO,CAAC;CACnB;
|
|
1
|
+
{"version":3,"file":"Form.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Form.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAG1D,MAAM,WAAW,KAAM,SAAQ,IAAI,CAAC,kBAAkB,EAAE,OAAO,CAAC;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,QAAQ,EAAE,OAAO,CAAC;CACnB;AA8BD,QAAA,MAAM,IAAI,2CAAwC,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
|
|
@@ -214,4 +214,11 @@ const sizeClasses = {
|
|
|
214
214
|
.toggle--lg .toggle__label {
|
|
215
215
|
font-size: var(--smrt-typography-body-large-size, 1rem);
|
|
216
216
|
}
|
|
217
|
+
|
|
218
|
+
@media (prefers-reduced-motion: reduce) {
|
|
219
|
+
.toggle__thumb,
|
|
220
|
+
.toggle__thumb::after {
|
|
221
|
+
transition: none;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
217
224
|
</style>
|
|
@@ -30,5 +30,7 @@ export declare const M: {
|
|
|
30
30
|
readonly 'ui.pagination.go_to_page': "ui.pagination.go_to_page";
|
|
31
31
|
readonly 'ui.pagination.next_page': "ui.pagination.next_page";
|
|
32
32
|
readonly 'ui.pagination.last_page': "ui.pagination.last_page";
|
|
33
|
+
readonly 'ui.reaction_picker.label': "ui.reaction_picker.label";
|
|
34
|
+
readonly 'ui.reaction_picker.react_with': "ui.reaction_picker.react_with";
|
|
33
35
|
};
|
|
34
36
|
//# sourceMappingURL=strings.ui.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"strings.ui.d.ts","sourceRoot":"","sources":["../../src/i18n/strings.ui.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,eAAO,MAAM,CAAC
|
|
1
|
+
{"version":3,"file":"strings.ui.d.ts","sourceRoot":"","sources":["../../src/i18n/strings.ui.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDZ,CAAC"}
|
package/dist/i18n/strings.ui.js
CHANGED
|
@@ -41,4 +41,7 @@ export const M = defineMessages({
|
|
|
41
41
|
'ui.pagination.go_to_page': 'Go to page {page}',
|
|
42
42
|
'ui.pagination.next_page': 'Next page',
|
|
43
43
|
'ui.pagination.last_page': 'Last page ({totalPages})',
|
|
44
|
+
// chat/ReactionPicker.svelte
|
|
45
|
+
'ui.reaction_picker.label': 'Add reaction',
|
|
46
|
+
'ui.reaction_picker.react_with': 'React with {emoji}',
|
|
44
47
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-ui",
|
|
3
|
-
"version": "0.34.
|
|
3
|
+
"version": "0.34.7",
|
|
4
4
|
"description": "Domain-agnostic Svelte 5 UI runtime for SMRT: primitives, i18n client, theme system, and module UI registry",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
},
|
|
115
115
|
"dependencies": {
|
|
116
116
|
"esm-env": "^1.2.2",
|
|
117
|
-
"@happyvertical/smrt-types": "0.34.
|
|
117
|
+
"@happyvertical/smrt-types": "0.34.7"
|
|
118
118
|
},
|
|
119
119
|
"peerDependencies": {
|
|
120
120
|
"svelte": "^5.18.2"
|