@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/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 `ui-check` (components.css, the core).
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 ? <span className="gb-tc-head__glyph" aria-hidden="true"><Icon name={KIND_ICON[kind]} size={16} /></span> : null}
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
- * The field and its buttons in one box (paperclip · text · send), as Slack: one row even at 320px. Enter sends,
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">
@@ -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}