@gemboss/ui 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +135 -7
- package/catalog/editor.html +1 -1
- package/catalog/index.html +147 -51
- package/gemboss-ui.css +3 -3
- package/package.json +10 -2
- package/parts/components.css +1 -1
- package/parts/core.css +2 -2
- package/parts/shell.css +1 -1
- package/src/assistant/engine.tsx +3 -2
- package/src/assistant-plain.js +1 -0
- package/src/assistant.css +1 -1
- package/src/briefing-card.css +1 -0
- package/src/briefing-card.tsx +50 -0
- package/src/editor.css +1 -1
- package/src/gemboss-tokens.css +1 -1
- package/src/icons.tsx +2 -0
- package/src/index.ts +8 -2
- package/src/progress-ring.css +1 -0
- package/src/progress-ring.tsx +65 -0
- package/src/question-card.css +1 -0
- package/src/question-card.tsx +116 -0
- package/src/range-bar.css +1 -0
- package/src/range-bar.tsx +102 -0
- package/src/reactions.tsx +32 -6
- package/src/spot-art.css +1 -0
- package/src/spot-art.tsx +130 -0
- package/src/team-chat-plain.js +1 -1
- package/src/team-chat.css +1 -1
- package/src/team-chat.tsx +106 -9
- package/src/theme-dark.css +1 -1
package/src/team-chat.tsx
CHANGED
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
* emit the same thing and the two cannot drift. Change the markup here, and the README block with it.
|
|
18
18
|
*
|
|
19
19
|
* Classes: `gb-tc-*` (team-chat.css, imported below). Reused from the package, not redrawn: `Avatar` (portal.css),
|
|
20
|
-
* `Reactions` / `ReactionPicker` (reactions.css), the kit's `ui-btn` and
|
|
20
|
+
* `Reactions` / `ReactionPicker` (reactions.css; the composer's emoji button opens the same picker), the kit's `ui-btn` and
|
|
21
|
+
* `ui-check` (components.css, the core).
|
|
21
22
|
*/
|
|
22
23
|
import "./team-chat.css";
|
|
23
24
|
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
24
25
|
import { Icon } from "./icons";
|
|
25
26
|
import { Avatar } from "./portal";
|
|
26
|
-
import { ReactionPicker, Reactions } from "./reactions";
|
|
27
|
+
import { ReactionPicker, Reactions, rememberRecent } from "./reactions";
|
|
27
28
|
import { QUICK_REACTIONS, type Reaction } from "./reactions-data";
|
|
28
29
|
|
|
29
30
|
const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");
|
|
@@ -57,6 +58,8 @@ export type TeamChatLabels = {
|
|
|
57
58
|
message: string;
|
|
58
59
|
send: string;
|
|
59
60
|
attach: string;
|
|
61
|
+
emoji: string;
|
|
62
|
+
pickEmoji: string;
|
|
60
63
|
stopReplying: string;
|
|
61
64
|
privateChannel: string;
|
|
62
65
|
directMessage: string;
|
|
@@ -93,6 +96,8 @@ export const TEAM_CHAT_LABELS: TeamChatLabels = {
|
|
|
93
96
|
message: "Message",
|
|
94
97
|
send: "Send",
|
|
95
98
|
attach: "Attach a file",
|
|
99
|
+
emoji: "Emoji",
|
|
100
|
+
pickEmoji: "Pick an emoji",
|
|
96
101
|
stopReplying: "Stop replying",
|
|
97
102
|
privateChannel: "private channel",
|
|
98
103
|
directMessage: "direct message",
|
|
@@ -246,8 +251,12 @@ export type ChannelItemProps = {
|
|
|
246
251
|
mentions?: number;
|
|
247
252
|
/** The channel on screen: `aria-current`. */
|
|
248
253
|
active?: boolean;
|
|
249
|
-
/** A direct message: whether the other person is online (a dot on the glyph). */
|
|
254
|
+
/** A direct message: whether the other person is online (a dot on the glyph, or on their picture). */
|
|
250
255
|
online?: boolean;
|
|
256
|
+
/** A direct message: the person's picture in place of the person glyph (21/10/2026), the kit's `Avatar` at the glyph's
|
|
257
|
+
* 20px, their initials and tone when there is no `src`; the presence dot sits on it. `name` defaults to the row's.
|
|
258
|
+
* Only on a DM: a channel keeps its glyph. Absent: the glyph, as before. */
|
|
259
|
+
avatar?: DmAvatar;
|
|
251
260
|
/** A link (the channel has its own URL) … */
|
|
252
261
|
href?: string;
|
|
253
262
|
/** … drawn by the app's link component (Next's `Link`). */
|
|
@@ -257,15 +266,18 @@ export type ChannelItemProps = {
|
|
|
257
266
|
labels?: Partial<TeamChatLabels>;
|
|
258
267
|
};
|
|
259
268
|
|
|
269
|
+
/** A person's picture on a DM (the rail's row, the conversation's head): an image URL, else initials from `name`. */
|
|
270
|
+
export type DmAvatar = { src?: string | null; name?: string };
|
|
271
|
+
|
|
260
272
|
/** One row of the rail. Its accessible name says the counts too: a dot cannot be read out. */
|
|
261
|
-
export function ChannelItem({ name, kind = "channel", unread = 0, mentions = 0, active = false, online, href, linkAs, onOpen, labels }: ChannelItemProps) {
|
|
273
|
+
export function ChannelItem({ name, kind = "channel", unread = 0, mentions = 0, active = false, online, avatar, href, linkAs, onOpen, labels }: ChannelItemProps) {
|
|
262
274
|
const t = words(labels);
|
|
263
275
|
const label = [name, kind === "private" ? t.privateChannel : kind === "dm" ? t.directMessage : null,
|
|
264
276
|
mentions ? t.mentioningYou(mentions) : null, unread ? t.unread(unread) : null].filter(Boolean).join(", ");
|
|
265
277
|
const inner = (
|
|
266
278
|
<>
|
|
267
279
|
<span className="gb-tc-channel__glyph" aria-hidden="true">
|
|
268
|
-
<Icon name={KIND_ICON[kind]} size={16} />
|
|
280
|
+
{kind === "dm" && avatar ? <Avatar name={avatar.name ?? name} src={avatar.src} size={24} className="gb-tc-channel__av" /> : <Icon name={KIND_ICON[kind]} size={16} />}
|
|
269
281
|
{kind === "dm" && online !== undefined ? <span className={PRESENCE_CLASS[online ? "online" : "offline"]} /> : null}
|
|
270
282
|
</span>
|
|
271
283
|
<span className="gb-tc-channel__name">{name}</span>
|
|
@@ -290,6 +302,9 @@ export type ChatHeaderProps = {
|
|
|
290
302
|
kind?: ChannelKind;
|
|
291
303
|
/** One line under the name: the topic, "Just the two of you", "12 members". */
|
|
292
304
|
topic?: React.ReactNode;
|
|
305
|
+
/** A direct message (`kind="dm"`): the person's picture left of the name in place of the glyph (21/10/2026), the kit's
|
|
306
|
+
* `Avatar` at 24px, initials and tone when there is no `src`. `name` defaults to the header's when it is text. */
|
|
307
|
+
avatar?: DmAvatar;
|
|
293
308
|
/** The phone's way back to the channel list (shown only at phone width). */
|
|
294
309
|
onBack?: () => void;
|
|
295
310
|
/** The "members" toggle: opens and closes the people panel. */
|
|
@@ -300,7 +315,7 @@ export type ChatHeaderProps = {
|
|
|
300
315
|
labels?: Partial<TeamChatLabels>;
|
|
301
316
|
};
|
|
302
317
|
|
|
303
|
-
export function ChatHeader({ name, kind, topic, onBack, members, actions, headingId, labels }: ChatHeaderProps) {
|
|
318
|
+
export function ChatHeader({ name, kind, topic, avatar, onBack, members, actions, headingId, labels }: ChatHeaderProps) {
|
|
304
319
|
const t = words(labels);
|
|
305
320
|
return (
|
|
306
321
|
<header className="gb-tc-head">
|
|
@@ -311,7 +326,8 @@ export function ChatHeader({ name, kind, topic, onBack, members, actions, headin
|
|
|
311
326
|
) : null}
|
|
312
327
|
<div className="gb-tc-head__text">
|
|
313
328
|
<h2 className="gb-tc-head__title" id={headingId}>
|
|
314
|
-
{kind ? <
|
|
329
|
+
{kind === "dm" && avatar ? <Avatar name={avatar.name ?? (typeof name === "string" ? name : "")} src={avatar.src} size={24} className="gb-tc-head__av" />
|
|
330
|
+
: kind ? <span className="gb-tc-head__glyph" aria-hidden="true"><Icon name={KIND_ICON[kind]} size={16} /></span> : null}
|
|
315
331
|
<span className="gb-tc-head__name">{name}</span>
|
|
316
332
|
</h2>
|
|
317
333
|
{topic ? <p className="gb-tc-head__sub">{topic}</p> : null}
|
|
@@ -582,6 +598,9 @@ export type ComposerProps = {
|
|
|
582
598
|
picker?: React.ReactNode;
|
|
583
599
|
/** Show the paperclip; the app opens its file input. */
|
|
584
600
|
onAttach?: () => void;
|
|
601
|
+
/** The emoji button (Chris 21/10/2026), on by default: it opens the reactions' picker (search, "Frequently used", the
|
|
602
|
+
* groups) over the field, and a pick goes in at the caret. `false` for an app that does not want it. */
|
|
603
|
+
emoji?: boolean;
|
|
585
604
|
/** The thread's "Also send to #general". */
|
|
586
605
|
also?: { label: React.ReactNode; checked: boolean; onChange: (checked: boolean) => void };
|
|
587
606
|
error?: React.ReactNode;
|
|
@@ -597,18 +616,84 @@ export type ComposerProps = {
|
|
|
597
616
|
};
|
|
598
617
|
|
|
599
618
|
/**
|
|
600
|
-
*
|
|
619
|
+
* Write `text` into a field over its selection (at its caret when nothing is selected) as typing would: focus stays in
|
|
620
|
+
* the field with the caret after the text, the browser's undo has it, and the field fires `input`, so React's onChange
|
|
621
|
+
* and any listener of the app's own hear it. `execCommand("insertText")` does all of that; where a browser refuses it,
|
|
622
|
+
* `setRangeText` and an `input` event. Nothing is written past the field's `maxLength`. Returns whether it wrote.
|
|
623
|
+
*/
|
|
624
|
+
function insertAtCaret(el: HTMLTextAreaElement, text: string, [from, to]: readonly [number, number]): boolean {
|
|
625
|
+
const before = el.value;
|
|
626
|
+
const s = Math.min(from, before.length), e = Math.min(Math.max(to, s), before.length);
|
|
627
|
+
el.focus({ preventScroll: true });
|
|
628
|
+
el.setSelectionRange(s, e);
|
|
629
|
+
if (el.maxLength >= 0 && before.length - (e - s) + text.length > el.maxLength) return false;
|
|
630
|
+
try { document.execCommand("insertText", false, text); } catch { /* not supported: below */ }
|
|
631
|
+
if (el.value === before) {
|
|
632
|
+
el.setRangeText(text, s, e, "end");
|
|
633
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
634
|
+
}
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Under a finger: a question about the device, as team-chat.css asks it (`pointer: coarse`). */
|
|
639
|
+
const coarsePointer = () => typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The field and its buttons in one box (paperclip · text · emoji · send), as Slack: one row even at 320px. Enter sends,
|
|
601
643
|
* Shift+Enter is a new line, and an IME composition (Vietnamese Telex, Japanese) is never cut short by its own Enter.
|
|
602
644
|
* The field grows with its text (`field-sizing: content`; where a browser lacks it, the component sets the height).
|
|
645
|
+
*
|
|
646
|
+
* The emoji button opens the reactions' own picker (`ReactionPicker` with no `items`) over the box, at its right edge
|
|
647
|
+
* (the box's whole width where the box is narrower than the picker: a phone). A pick goes in at the caret, over a
|
|
648
|
+
* selection, and joins "Frequently used"; focus stays in the field, after the emoji. Escape closes it and puts focus back
|
|
649
|
+
* in the field where the caret was; a press or focus anywhere else closes it. Under a finger it opens on the emoji, not
|
|
650
|
+
* the search, so the keyboard does not come up over it.
|
|
603
651
|
*/
|
|
604
652
|
export function Composer({ onSend, value, defaultValue = "", onValueChange, placeholder, label, id, disabled, busy, canSendEmpty, replyingTo, onCancelReply,
|
|
605
|
-
files, picker, onAttach, also, error, inputRef, onKeyDown, onPaste, maxLength = 4000, maxRows = 8, className, labels }: ComposerProps) {
|
|
653
|
+
files, picker, onAttach, emoji = true, also, error, inputRef, onKeyDown, onPaste, maxLength = 4000, maxRows = 8, className, labels }: ComposerProps) {
|
|
606
654
|
const t = words(labels);
|
|
607
655
|
const auto = useId();
|
|
608
656
|
const fieldId = id ?? `${auto}-input`;
|
|
657
|
+
const popId = useId();
|
|
609
658
|
const [own, setOwn] = useState(defaultValue);
|
|
610
659
|
const text = value ?? own;
|
|
611
660
|
const field = useRef<HTMLTextAreaElement | null>(null);
|
|
661
|
+
const [emojiOpen, setEmojiOpen] = useState(false);
|
|
662
|
+
const [emojiFocus, setEmojiFocus] = useState<"search" | "emoji">("search");
|
|
663
|
+
const emojiBtn = useRef<HTMLButtonElement>(null);
|
|
664
|
+
// Where the pick goes: the field's selection when the picker opened. A field never focused has no caret yet: its end.
|
|
665
|
+
const touched = useRef(false);
|
|
666
|
+
const at = useRef<[number, number]>([0, 0]);
|
|
667
|
+
const openEmoji = () => {
|
|
668
|
+
const el = field.current, n = el?.value.length ?? 0;
|
|
669
|
+
at.current = el && touched.current ? [el.selectionStart ?? n, el.selectionEnd ?? n] : [n, n];
|
|
670
|
+
setEmojiFocus(coarsePointer() ? "emoji" : "search");
|
|
671
|
+
setEmojiOpen(true);
|
|
672
|
+
};
|
|
673
|
+
const backToField = () => {
|
|
674
|
+
const el = field.current;
|
|
675
|
+
if (!el) return;
|
|
676
|
+
el.focus({ preventScroll: true });
|
|
677
|
+
el.setSelectionRange(Math.min(at.current[0], el.value.length), Math.min(at.current[1], el.value.length));
|
|
678
|
+
};
|
|
679
|
+
const pickEmoji = (e: string) => {
|
|
680
|
+
setEmojiOpen(false);
|
|
681
|
+
rememberRecent(e);
|
|
682
|
+
if (field.current) insertAtCaret(field.current, e, at.current);
|
|
683
|
+
};
|
|
684
|
+
// A press or focus outside the picker and its button closes it (and leaves focus where the person put it).
|
|
685
|
+
useEffect(() => {
|
|
686
|
+
if (!emojiOpen) return;
|
|
687
|
+
const away = (ev: Event) => {
|
|
688
|
+
const n = ev.target as Node | null, pop = document.getElementById(popId);
|
|
689
|
+
if (n && (pop?.contains(n) || emojiBtn.current?.contains(n))) return;
|
|
690
|
+
setEmojiOpen(false);
|
|
691
|
+
};
|
|
692
|
+
document.addEventListener("pointerdown", away, true);
|
|
693
|
+
document.addEventListener("focusin", away, true);
|
|
694
|
+
return () => { document.removeEventListener("pointerdown", away, true); document.removeEventListener("focusin", away, true); };
|
|
695
|
+
}, [emojiOpen, popId]);
|
|
696
|
+
useEffect(() => { if (disabled) setEmojiOpen(false); }, [disabled]);
|
|
612
697
|
const setField = (el: HTMLTextAreaElement | null) => {
|
|
613
698
|
field.current = el;
|
|
614
699
|
if (typeof inputRef === "function") inputRef(el);
|
|
@@ -651,15 +736,27 @@ export function Composer({ onSend, value, defaultValue = "", onValueChange, plac
|
|
|
651
736
|
<textarea ref={setField} id={fieldId} className="gb-tc-composer__input" rows={1} maxLength={maxLength} placeholder={placeholder}
|
|
652
737
|
value={text} disabled={disabled} onPaste={onPaste}
|
|
653
738
|
onChange={(e) => set(e.target.value)}
|
|
739
|
+
onFocus={() => { touched.current = true; }}
|
|
654
740
|
onKeyDown={(e) => {
|
|
655
741
|
onKeyDown?.(e);
|
|
656
742
|
if (e.defaultPrevented || e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
657
743
|
e.preventDefault();
|
|
658
744
|
send();
|
|
659
745
|
}} />
|
|
746
|
+
{emoji ? (
|
|
747
|
+
<button ref={emojiBtn} type="button" className="ui-btn ui-btn--ghost ui-btn--icon gb-tc-composer__emoji" aria-label={t.emoji}
|
|
748
|
+
aria-expanded={emojiOpen} aria-controls={emojiOpen ? popId : undefined} disabled={disabled}
|
|
749
|
+
onClick={() => (emojiOpen ? setEmojiOpen(false) : openEmoji())}>
|
|
750
|
+
<Icon name="emoji" />
|
|
751
|
+
</button>
|
|
752
|
+
) : null}
|
|
660
753
|
<button type="submit" className="ui-btn ui-btn--primary ui-btn--icon gb-tc-composer__send" aria-label={t.send} disabled={!can}>
|
|
661
754
|
<Icon name="send" />
|
|
662
755
|
</button>
|
|
756
|
+
{emoji && emojiOpen ? (
|
|
757
|
+
<ReactionPicker id={popId} className="gb-tc-composer__emoji-pop" label={t.pickEmoji} initialFocus={emojiFocus}
|
|
758
|
+
onPick={pickEmoji} onClose={() => { setEmojiOpen(false); backToField(); }} />
|
|
759
|
+
) : null}
|
|
663
760
|
</div>
|
|
664
761
|
{also ? (
|
|
665
762
|
<label className="ui-check gb-tc-composer__also">
|
package/src/theme-dark.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
:root[data-theme=dark]{color-scheme:dark;--gemboss-primary: #f2f3f5;--gemboss-primary-dark: #ffffff;--gemboss-primary-soft: #23242a;--gemboss-on-primary: #0d0d12;--gemboss-accent: #7c84ff;--gemboss-accent-strong: #a3a9ff;--gemboss-accent-soft: #1f2140;--gemboss-text-strong: #f2f3f5;--gemboss-text: #e3e4e8;--gemboss-text-muted: #9a9ca6;--gemboss-surface: #16171d;--gemboss-canvas: #0d0d12;--gemboss-app-bg: #0d0d12;--gemboss-rail-bg: #0d0d12;--gemboss-panel-bg: #16171d;--gemboss-topbar-bg: rgba(13, 13, 18, .96);--gemboss-nav-bg: #0d0d12;--gemboss-nav-hover: rgba(255, 255, 255, .06);--gemboss-divider: #2a2b33;--gemboss-divider-soft: #22232a;--gemboss-hover: #22232b;--gemboss-active-bg: #262833;--gemboss-active-fg: #f2f3f5;--gemboss-field-bg: #121318;--gemboss-field-bg-hover: #17181e;--gemboss-field-ink: #e3e4e8;--gemboss-field-placeholder: #8a8c96;--gemboss-field-line: #3a3c46;--gemboss-field-line-hover: #50525e;--gemboss-field-line-active: #c9cbd3;--gemboss-chip-ink: #d6d8de;--gemboss-chip-line: #3a3c46;--gemboss-chip-line-hover: #50525e;--gemboss-chip-bg-hover: #22232b;--gemboss-success: #34c38f;--gemboss-success-strong: #5fd6a8;--gemboss-success-soft: #13291f;--gemboss-success-border: #22533e;--gemboss-warning: #e0b341;--gemboss-warning-soft: #2b2412;--gemboss-warning-border: #5a4a1d;--gemboss-danger: #ff6b5a;--gemboss-danger-strong: #ff8a7c;--gemboss-danger-soft: #2e1714;--gemboss-danger-border: #5c2a24;--ui-accent-fg: #0d0d12;--ui-warning-fg: #e8c063;--ui-card-shadow: none;--gb-brand-ink: #f2f3f5}:root[data-theme=dark] .gb-avatar[data-tone]{background:color-mix(in srgb,var(--gb-tone) 30%,#16171d);color:color-mix(in srgb,var(--gb-tone) 35%,#ffffff)}:root[data-theme=dark] body{background:var(--gemboss-app-bg);color:var(--gemboss-text)}:root[data-theme=dark] .gb-leaderboard-row.is-first .gb-leaderboard-value,:root[data-theme=dark] .gb-podium-slot--1 .gb-podium-value{color:#e0b341}
|
|
1
|
+
:root[data-theme=dark]{color-scheme:dark;--gemboss-primary: #f2f3f5;--gemboss-primary-dark: #ffffff;--gemboss-primary-soft: #23242a;--gemboss-on-primary: #0d0d12;--gemboss-accent: #7c84ff;--gemboss-accent-strong: #a3a9ff;--gemboss-accent-soft: #1f2140;--gemboss-accent-wash: #181a33;--gemboss-accent-tint: #2a2e5c;--gemboss-accent-mid: #3b4180;--gemboss-text-strong: #f2f3f5;--gemboss-text: #e3e4e8;--gemboss-text-muted: #9a9ca6;--gemboss-surface: #16171d;--gemboss-canvas: #0d0d12;--gemboss-app-bg: #0d0d12;--gemboss-rail-bg: #0d0d12;--gemboss-panel-bg: #16171d;--gemboss-topbar-bg: rgba(13, 13, 18, .96);--gemboss-nav-bg: #0d0d12;--gemboss-nav-hover: rgba(255, 255, 255, .06);--gemboss-divider: #2a2b33;--gemboss-divider-soft: #22232a;--gemboss-hover: #22232b;--gemboss-active-bg: #262833;--gemboss-active-fg: #f2f3f5;--gemboss-field-bg: #121318;--gemboss-field-bg-hover: #17181e;--gemboss-field-ink: #e3e4e8;--gemboss-field-placeholder: #8a8c96;--gemboss-field-line: #3a3c46;--gemboss-field-line-hover: #50525e;--gemboss-field-line-active: #c9cbd3;--gemboss-chip-ink: #d6d8de;--gemboss-chip-line: #3a3c46;--gemboss-chip-line-hover: #50525e;--gemboss-chip-bg-hover: #22232b;--gemboss-success: #34c38f;--gemboss-success-strong: #5fd6a8;--gemboss-success-soft: #13291f;--gemboss-success-border: #22533e;--gemboss-warning: #e0b341;--gemboss-warning-soft: #2b2412;--gemboss-warning-border: #5a4a1d;--gemboss-danger: #ff6b5a;--gemboss-danger-strong: #ff8a7c;--gemboss-danger-soft: #2e1714;--gemboss-danger-border: #5c2a24;--ui-accent-fg: #0d0d12;--ui-warning-fg: #e8c063;--ui-card-shadow: none;--gb-brand-ink: #f2f3f5}:root[data-theme=dark] .gb-avatar[data-tone]{background:color-mix(in srgb,var(--gb-tone) 30%,#16171d);color:color-mix(in srgb,var(--gb-tone) 35%,#ffffff)}:root[data-theme=dark] body{background:var(--gemboss-app-bg);color:var(--gemboss-text)}:root[data-theme=dark] .gb-leaderboard-row.is-first .gb-leaderboard-value,:root[data-theme=dark] .gb-podium-slot--1 .gb-podium-value{color:#e0b341}
|