@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.
@@ -1,26 +1,50 @@
1
1
  <script lang="ts">
2
2
  /**
3
- * MessageBubble — a single chat message with user / agent / system variants.
3
+ * MessageBubble — a single chat message, tokenised and a11y-clean.
4
4
  *
5
- * Renders the author + an optional `<time>` timestamp, the message body
6
- * (`children`), and optional `reactions` / `actions` snippets. The bubble is a
7
- * labelled `role="group"` (`<author>, <role>`) so assistive tech can navigate
8
- * messages and announce who sent each one.
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
- /** Who sent the message. Drives styling and the group label. */
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
- /** Display name of the sender. */
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
- const groupLabel = $derived(`${author ?? roleNoun[role]} (${role})`);
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 class="bubble bubble--{role}" role="group" aria-label={groupLabel}>
54
- <div class="bubble__header">
55
- <span class="bubble__author">{author ?? roleNoun[role]}</span>
56
- {#if timeText}
57
- <time class="bubble__time" datetime={isoTime}>{timeText}</time>
58
- {/if}
59
- </div>
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
- {@render children()}
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: 42rem;
80
- padding: var(--smrt-spacing-2, 8px) var(--smrt-spacing-3, 12px);
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
- background: var(--smrt-color-surface-container, #f3edf7);
83
- color: var(--smrt-color-on-surface);
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
- .bubble--user {
87
- background: var(--smrt-color-primary-container);
88
- color: var(--smrt-color-on-primary-container);
89
- align-self: flex-end;
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
- background: var(--smrt-color-surface-container-high);
93
- color: var(--smrt-color-on-surface-variant);
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
- font-style: italic;
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 with user / agent / system variants.
2
+ * MessageBubble — a single chat message, tokenised and a11y-clean.
3
3
  *
4
- * Renders the author + an optional `<time>` timestamp, the message body
5
- * (`children`), and optional `reactions` / `actions` snippets. The bubble is a
6
- * labelled `role="group"` (`<author>, <role>`) so assistive tech can navigate
7
- * messages and announce who sent each one.
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
- /** Who sent the message. Drives styling and the group label. */
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
- /** Display name of the sender. */
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;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAG3D,MAAM,WAAW,KAAK;IACpB,gEAAgE;IAChE,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,kCAAkC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,uEAAuE;IACvE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oBAAoB;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,qDAAqD;IACrD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAuDD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
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 has a text accessible name (so the emoji isn't announced as raw
6
- * glyphs), and the group carries an `aria-label`. Fires `onpick(emoji)` on
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 = 'Add reaction',
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
- <div class="reactions" role="group" aria-label={label}>
36
- {#each emojis as emoji (emoji)}
37
- <button
38
- type="button"
39
- class="reactions__btn"
40
- aria-label={EMOJI_LABELS[emoji] ?? `React with ${emoji}`}
41
- onclick={() => onpick?.(emoji)}
42
- >
43
- <span aria-hidden="true">{emoji}</span>
44
- </button>
45
- {/each}
46
- </div>
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
- border-radius: var(--smrt-radius-full, 9999px);
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":"AAGA;;;;;;GAMG;AACH,MAAM,WAAW,KAAK;IACpB,0BAA0B;IAC1B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC;AAiCD,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
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
- * The visible dots are decorative (`aria-hidden`); the meaning is carried by an
6
- * sr-only label inside a polite `role="status"` live region so screen readers
7
- * hear "<name> is typing" without the animation noise. Honors reduced-motion.
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 (e.g. "Assistant"). */
15
+ /** Who is typing (single). */
11
16
  name?: string;
12
- /** Full label override; defaults to "<name> is typing…" / "Typing…". */
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
- const text = $derived(label ?? (name ? `${name} is typing…` : 'Typing…'));
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
- <div class="typing" role="status" aria-live="polite">
21
- <span class="typing__sr">{text}</span>
22
- <span class="typing__dots" aria-hidden="true">
23
- <span class="typing__dot"></span>
24
- <span class="typing__dot"></span>
25
- <span class="typing__dot"></span>
26
- </span>
27
- </div>
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-1, 4px);
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
- .typing__sr {
41
- position: absolute;
42
- width: 1px;
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
- * The visible dots are decorative (`aria-hidden`); the meaning is carried by an
5
- * sr-only label inside a polite `role="status"` live region so screen readers
6
- * hear "<name> is typing" without the animation noise. Honors reduced-motion.
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 (e.g. "Assistant"). */
14
+ /** Who is typing (single). */
10
15
  name?: string;
11
- /** Full label override; defaults to "<name> is typing…" / "Typing…". */
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;;;;;;GAMG;AACH,MAAM,WAAW,KAAK;IACpB,wCAAwC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAqBD,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
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('is axe-clean', async () => {
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
- <style>
48
- .form {
49
- display: block;
50
- }
51
- </style>
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;AA6BD,QAAA,MAAM,IAAI,2CAAwC,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
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"}
@@ -80,4 +80,10 @@ const resolvedInvalid = $derived(
80
80
  .input::placeholder {
81
81
  color: var(--smrt-color-on-surface-variant, #9ca3af);
82
82
  }
83
+
84
+ @media (prefers-reduced-motion: reduce) {
85
+ .input {
86
+ transition: none;
87
+ }
88
+ }
83
89
  </style>
@@ -80,4 +80,10 @@ const resolvedInvalid = $derived(
80
80
  cursor: not-allowed;
81
81
  opacity: 0.7;
82
82
  }
83
+
84
+ @media (prefers-reduced-motion: reduce) {
85
+ .select {
86
+ transition: none;
87
+ }
88
+ }
83
89
  </style>
@@ -82,4 +82,10 @@ const resolvedInvalid = $derived(
82
82
  .textarea::placeholder {
83
83
  color: var(--smrt-color-on-surface-variant, #9ca3af);
84
84
  }
85
+
86
+ @media (prefers-reduced-motion: reduce) {
87
+ .textarea {
88
+ transition: none;
89
+ }
90
+ }
85
91
  </style>
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;CA4CZ,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"}
@@ -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.6",
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.6"
117
+ "@happyvertical/smrt-types": "0.34.7"
118
118
  },
119
119
  "peerDependencies": {
120
120
  "svelte": "^5.18.2"