@gemboss/ui 0.13.0 → 0.21.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 +362 -1
- package/README.md +253 -12
- package/catalog/editor.html +611 -0
- package/catalog/index.html +998 -25
- package/gemboss-ui.css +3 -4698
- package/package.json +15 -4
- package/src/app-shell.css +1 -256
- package/src/assistant.css +1 -558
- package/src/auth.css +1 -606
- package/src/board-move.ts +194 -0
- package/src/chat-composer.css +1 -110
- package/src/chat.css +1 -432
- package/src/collections.css +1 -350
- package/src/collections.tsx +169 -40
- package/src/editor/controls.tsx +206 -0
- package/src/editor/css-url.ts +11 -0
- package/src/editor/frame.tsx +242 -0
- package/src/editor/index.ts +43 -0
- package/src/editor/panel.tsx +188 -0
- package/src/editor/panes.tsx +44 -0
- package/src/editor/swatch.ts +45 -0
- package/src/editor-plain.js +1 -0
- package/src/editor.css +1 -1099
- package/src/gemboss-field.css +1 -112
- package/src/gemboss-tokens.css +1 -164
- package/src/icons.tsx +2 -0
- package/src/index-table.tsx +19 -56
- package/src/index.ts +12 -4
- package/src/kit-plain.css +1 -499
- package/src/kit-plain.js +1 -575
- package/src/menu-layer.ts +87 -0
- package/src/menu.tsx +28 -2
- package/src/portal.css +1 -139
- package/src/reactions-data.ts +59 -0
- package/src/reactions.css +1 -0
- package/src/reactions.tsx +139 -0
- package/src/resource-list-select.tsx +72 -0
- package/src/segmented-click.ts +20 -0
- package/src/segmented.tsx +98 -0
- package/src/selection.tsx +78 -0
- package/src/shell.css +1 -1079
- package/src/theme-dark.css +1 -66
- package/src/ui.css +1 -610
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu layer (0.18.0): an open `details.ui-menu` shows its list in the browser's TOP LAYER (the `popover` API), placed
|
|
3
|
+
* with `position: fixed` against its ⋯ button, so no scrolling frame can cut it off. Before, the list was
|
|
4
|
+
* `position: absolute` inside the row, and a table's frame (`.ui-table-wrap`, overflow auto) clipped the menu of the
|
|
5
|
+
* last rows: GemFactory Members (#197) at 820 / 900 / 1280 showed half a menu and asked for a scroll INSIDE the table.
|
|
6
|
+
*
|
|
7
|
+
* - Below its button when it fits, above it when the room below is short and the room above is larger (a last row near
|
|
8
|
+
* the bottom of the screen); `ui-menu--end` aligns its right edge with the button's; never closer than 8px to a
|
|
9
|
+
* viewport edge; taller than the room → it scrolls inside (`max-height`).
|
|
10
|
+
* - It stays the details' DOM child: Tab order (button, then the items), Escape (initMenus / Menu close the details and
|
|
11
|
+
* put focus back on the button), "a click on the menu is not a click on the row" (ROW_CONTROLS) all keep working.
|
|
12
|
+
* - It CLOSES when a scroll moves its button (the page, or a frame around the menu) or the window resizes: a fixed list
|
|
13
|
+
* would otherwise float away from it. A scroll inside the list, or of something elsewhere (a log following its
|
|
14
|
+
* output, a carousel), does not count.
|
|
15
|
+
* - No popover API (Safari < 17): nothing changes, the list stays absolute as before 0.18.0. No script: the same.
|
|
16
|
+
*
|
|
17
|
+
* kit-plain.js carries the same code for plain-HTML surfaces (`initMenuLayer()`, run by itself); collections-board.test.ts
|
|
18
|
+
* runs the same geometry tests against both, so they cannot drift apart.
|
|
19
|
+
*/
|
|
20
|
+
const GAP = 4;
|
|
21
|
+
const EDGE = 8;
|
|
22
|
+
|
|
23
|
+
export function canFloatMenus(): boolean {
|
|
24
|
+
return typeof HTMLElement !== "undefined" && Object.prototype.hasOwnProperty.call(HTMLElement.prototype, "popover");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const partsOf = (d: HTMLDetailsElement) => ({
|
|
28
|
+
list: d.querySelector<HTMLElement>(":scope > .ui-menu__list"),
|
|
29
|
+
button: d.querySelector<HTMLElement>(":scope > summary"),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Puts an OPEN menu's list in the top layer and places it against its button. Idempotent (a second call re-places). */
|
|
33
|
+
export function floatMenu(d: HTMLDetailsElement): void {
|
|
34
|
+
const { list, button } = partsOf(d);
|
|
35
|
+
if (!list || !button || !d.open || !canFloatMenus()) return;
|
|
36
|
+
if (list.getAttribute("popover") !== "manual") list.setAttribute("popover", "manual");
|
|
37
|
+
if (!list.matches(":popover-open")) list.showPopover();
|
|
38
|
+
list.style.maxHeight = "";
|
|
39
|
+
list.style.left = "0px";
|
|
40
|
+
list.style.top = "0px";
|
|
41
|
+
const r = button.getBoundingClientRect();
|
|
42
|
+
const vw = document.documentElement.clientWidth, vh = window.innerHeight;
|
|
43
|
+
const w = list.offsetWidth, h = list.offsetHeight;
|
|
44
|
+
const below = vh - r.bottom - GAP - EDGE, above = r.top - GAP - EDGE;
|
|
45
|
+
let top: number;
|
|
46
|
+
if (h <= below || below >= above) {
|
|
47
|
+
top = r.bottom + GAP;
|
|
48
|
+
if (h > below) list.style.maxHeight = `${Math.max(below, 0)}px`;
|
|
49
|
+
list.dataset.side = "bottom";
|
|
50
|
+
} else {
|
|
51
|
+
if (h > above) list.style.maxHeight = `${Math.max(above, 0)}px`;
|
|
52
|
+
top = r.top - GAP - Math.min(h, above);
|
|
53
|
+
list.dataset.side = "top";
|
|
54
|
+
}
|
|
55
|
+
let left = d.classList.contains("ui-menu--end") ? r.right - w : r.left;
|
|
56
|
+
left = Math.max(EDGE, Math.min(left, vw - w - EDGE));
|
|
57
|
+
list.style.left = `${Math.round(left)}px`;
|
|
58
|
+
list.style.top = `${Math.round(top)}px`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Takes a closed menu's list out of the top layer. The `popover` attribute stays: hidden until the next float. */
|
|
62
|
+
export function sinkMenu(d: HTMLDetailsElement): void {
|
|
63
|
+
const { list } = partsOf(d);
|
|
64
|
+
if (list && list.hasAttribute("popover") && list.matches(":popover-open")) list.hidePopover();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Closes the floating menus a scroll or a resize would leave behind: a scroll closes only the menus whose button it
|
|
68
|
+
* moves (the document, or a scroller that contains the menu), never because of a scroll inside a list, a log that
|
|
69
|
+
* follows its output or a carousel elsewhere on the page. `target` null = a resize: every floating menu. */
|
|
70
|
+
export function closeDetachedMenus(target: EventTarget | null): void {
|
|
71
|
+
const t = target as (Node & { closest?: (s: string) => Element | null }) | null;
|
|
72
|
+
if (t && t.closest && t.closest(".ui-menu__list")) return;
|
|
73
|
+
document.querySelectorAll<HTMLDetailsElement>("details.ui-menu[open]").forEach((d) => {
|
|
74
|
+
const list = d.querySelector<HTMLElement>(":scope > .ui-menu__list");
|
|
75
|
+
if (!list || !list.matches(":popover-open")) return;
|
|
76
|
+
if (!t || t === document || (t as Node).nodeType === 9 || (t as Node).contains(d)) d.open = false;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let listening = false;
|
|
81
|
+
/** One scroll (capture) and one resize listener for every floating menu on the page, whatever the number of Menus. */
|
|
82
|
+
export function listenForDetachedMenus(): void {
|
|
83
|
+
if (listening || typeof document === "undefined") return;
|
|
84
|
+
listening = true;
|
|
85
|
+
document.addEventListener("scroll", (e) => closeDetachedMenus(e.target), true);
|
|
86
|
+
window.addEventListener("resize", () => closeDetachedMenus(null));
|
|
87
|
+
}
|
package/src/menu.tsx
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
// fleet looks the same (§9.3). Where React hydrates, the menu closes itself like AccountMenu does: outside
|
|
7
7
|
// click, Escape (focus back on the trigger), and a button row after its handler (review #3591: a React page
|
|
8
8
|
// had no clean way to close it). The catalog renders its example from these components.
|
|
9
|
+
// 0.18.0: the open list floats in the top layer (./menu-layer), so a table's scrolling frame no longer cuts it; it
|
|
10
|
+
// closes when the page scrolls or the window resizes.
|
|
9
11
|
import React, { useEffect, useRef } from "react";
|
|
10
12
|
import { Icon } from "./icons";
|
|
13
|
+
import { canFloatMenus, floatMenu, listenForDetachedMenus, sinkMenu } from "./menu-layer";
|
|
11
14
|
|
|
12
15
|
/** The trigger is an icon button (`label` is its accessible name). `end` aligns the list to the right edge. */
|
|
13
16
|
export function Menu({ label, icon, end, children, className }: {
|
|
@@ -23,13 +26,36 @@ export function Menu({ label, icon, end, children, className }: {
|
|
|
23
26
|
if (!el) return undefined;
|
|
24
27
|
const onDoc = (e: MouseEvent) => { if (el.open && !el.contains(e.target as Node)) el.open = false; };
|
|
25
28
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && el.open) { el.open = false; el.querySelector("summary")?.focus(); } };
|
|
29
|
+
const float = canFloatMenus();
|
|
30
|
+
const onToggle = () => { if (el.open) floatMenu(el); else sinkMenu(el); };
|
|
26
31
|
document.addEventListener("click", onDoc);
|
|
27
32
|
document.addEventListener("keydown", onKey);
|
|
28
|
-
|
|
33
|
+
if (float) {
|
|
34
|
+
el.addEventListener("toggle", onToggle);
|
|
35
|
+
// One page-wide pair of listeners for all menus (a 100-row table has 100 Menus), not one per menu.
|
|
36
|
+
listenForDetachedMenus();
|
|
37
|
+
// Opened before this effect ran (a click during hydration): float it now.
|
|
38
|
+
if (el.open) floatMenu(el);
|
|
39
|
+
}
|
|
40
|
+
return () => {
|
|
41
|
+
document.removeEventListener("click", onDoc);
|
|
42
|
+
document.removeEventListener("keydown", onKey);
|
|
43
|
+
el.removeEventListener("toggle", onToggle);
|
|
44
|
+
};
|
|
29
45
|
}, []);
|
|
46
|
+
// The summary toggles the menu itself and floats it in the same task, so the list is never painted inside the frame
|
|
47
|
+
// first (the details' own `toggle` event comes a task later). Nothing page-wide: kit-plain.js's `data-gb-menus` rule
|
|
48
|
+
// would hide a hand-written `details.ui-menu` on a React page that nobody floats.
|
|
49
|
+
const onSummary = (e: React.MouseEvent<HTMLElement>) => {
|
|
50
|
+
const el = ref.current;
|
|
51
|
+
if (!el || !canFloatMenus()) return;
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
el.open = !el.open;
|
|
54
|
+
if (el.open) floatMenu(el); else sinkMenu(el);
|
|
55
|
+
};
|
|
30
56
|
return (
|
|
31
57
|
<details ref={ref} className={["ui-menu", end ? "ui-menu--end" : "", className ?? ""].filter(Boolean).join(" ")}>
|
|
32
|
-
<summary className="ui-btn ui-btn--icon" aria-label={label} title={label}>{icon ?? <Icon name="more" />}</summary>
|
|
58
|
+
<summary className="ui-btn ui-btn--icon" aria-label={label} title={label} onClick={onSummary}>{icon ?? <Icon name="more" />}</summary>
|
|
33
59
|
<div className="ui-menu__list gemboss-popover-surface">{children}</div>
|
|
34
60
|
</details>
|
|
35
61
|
);
|
package/src/portal.css
CHANGED
|
@@ -1,139 +1 @@
|
|
|
1
|
-
|
|
2
|
-
TopbarNav, Avatar, Leaderboard, Podium (portal.tsx), SearchDialog (dialog.tsx), ThemeToggle (theme.tsx).
|
|
3
|
-
Component rules only, no rule on :root, html or body (a test holds that): a surface with its own skin
|
|
4
|
-
can take this file with gemboss-tokens.css alone. The dark palette is theme-dark.css. */
|
|
5
|
-
|
|
6
|
-
/* ── TopbarNav: section tabs in the topbar, after the brand (Featurebase's "Feedback · Roadmap"). The
|
|
7
|
-
active tab is underlined on the topbar's bottom edge. 44px targets under a finger, 36 under a mouse
|
|
8
|
-
(the kit's two-tier rule, as TopbarSearch). A phone scrolls the row sideways rather than wrapping it. */
|
|
9
|
-
.gb-topnav { display: flex; align-items: stretch; gap: 4px; min-width: 0; height: 100%; margin-left: 12px; padding-left: 12px;
|
|
10
|
-
border-left: 1px solid var(--gemboss-divider); overflow-x: auto; scrollbar-width: none; }
|
|
11
|
-
.gb-topnav::-webkit-scrollbar { display: none; }
|
|
12
|
-
/* Each tab is as tall as the topbar, so its underline lands on the topbar's bottom edge. */
|
|
13
|
-
.gb-topnav-link { position: relative; display: inline-flex; align-items: center; align-self: stretch; flex: 0 0 auto; min-height: 36px; padding: 0 10px;
|
|
14
|
-
border-radius: 8px; color: var(--gemboss-text-muted); font-size: 14px; font-weight: 520; text-decoration: none; white-space: nowrap; }
|
|
15
|
-
.gb-topnav-link:hover { color: var(--gemboss-text-strong); background: var(--gemboss-hover); }
|
|
16
|
-
.gb-topnav-link:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
|
|
17
|
-
.gb-topnav-link[aria-current="page"] { color: var(--gemboss-text-strong); font-weight: 640; }
|
|
18
|
-
.gb-topnav-link[aria-current="page"]::after { content: ""; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 2px;
|
|
19
|
-
border-radius: 2px; background: var(--gemboss-text-strong); }
|
|
20
|
-
@media (pointer: coarse) { .gb-topnav-link { min-height: 44px; } }
|
|
21
|
-
/* A topbar that carries tabs has no centre search: brand + tabs take the room, actions sit right. The
|
|
22
|
-
default columns keep 280–560px for a search box, which cut "Roadmap" to "Ro" and the brand to "G…". */
|
|
23
|
-
.gemboss-platform-topbar:has(.gb-topnav):has(> .gemboss-topbar-center:empty) { grid-template-columns: minmax(0, 1fr) auto; }
|
|
24
|
-
.gemboss-platform-topbar:has(.gb-topnav) > .gemboss-topbar-center:empty { display: none; }
|
|
25
|
-
.gemboss-topbar-start:has(.gb-topnav) { min-width: 0; height: 100%; }
|
|
26
|
-
.gemboss-topbar-start:has(.gb-topnav) > .gb-brand { flex: 0 0 auto; }
|
|
27
|
-
@media (max-width: 760px) { .gb-topnav { margin-left: 8px; padding-left: 8px; } .gb-topnav-link { min-height: 44px; padding: 0 8px; }
|
|
28
|
-
}
|
|
29
|
-
.gb-topnav-row { display: flex; align-items: stretch; gap: 4px; min-width: 0; height: 100%; }
|
|
30
|
-
/* The folded form (under 600px): one button naming the open tab, the tabs in a popover under it. */
|
|
31
|
-
.gb-topnav-fold { display: none; position: relative; align-self: stretch; }
|
|
32
|
-
.gb-topnav-fold > summary { list-style: none; cursor: pointer; gap: 4px; height: 100%; }
|
|
33
|
-
.gb-topnav-fold > summary::-webkit-details-marker { display: none; }
|
|
34
|
-
.gb-topnav-fold-btn[aria-current="page"]::after { display: none; }
|
|
35
|
-
.gb-topnav-chevron { flex: 0 0 auto; transition: transform .15s ease; }
|
|
36
|
-
.gb-topnav-fold[open] .gb-topnav-chevron { transform: rotate(180deg); }
|
|
37
|
-
.gb-topnav-pop { position: fixed; top: 56px; left: 8px; min-width: 200px; padding: 6px; display: grid; gap: 2px; z-index: 60; }
|
|
38
|
-
.gb-topnav-item { display: flex; align-items: center; min-height: 44px; padding: 0 12px; border-radius: 8px;
|
|
39
|
-
color: var(--gemboss-text); font-size: 14px; font-weight: 520; text-decoration: none; }
|
|
40
|
-
.gb-topnav-item:hover { background: var(--gemboss-hover); }
|
|
41
|
-
.gb-topnav-item:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
|
|
42
|
-
.gb-topnav-item[aria-current="page"] { color: var(--gemboss-active-fg); background: var(--gemboss-active-bg); font-weight: 640; }
|
|
43
|
-
@media (max-width: 600px) {
|
|
44
|
-
.gb-topnav { overflow: visible; }
|
|
45
|
-
.gb-topnav-row { display: none; }
|
|
46
|
-
.gb-topnav-fold { display: flex; }
|
|
47
|
-
}
|
|
48
|
-
@media (prefers-reduced-motion: reduce) { .gb-topnav-chevron { transition: none; } }
|
|
49
|
-
|
|
50
|
-
/* Text for screen readers only ("Rank 1:", ", you"). Its own rule, not ui.css's .ui-sr-only: a surface that
|
|
51
|
-
takes portal.css without the rest of the kit (GemCommunity) would otherwise print it on the page. */
|
|
52
|
-
.gb-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0);
|
|
53
|
-
white-space: nowrap; border: 0; }
|
|
54
|
-
|
|
55
|
-
/* ── Avatar: picture or initials on one of eight tones picked from the name. */
|
|
56
|
-
.gb-avatar { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 50%; overflow: hidden;
|
|
57
|
-
font-weight: 700; line-height: 1; letter-spacing: .01em; user-select: none; }
|
|
58
|
-
.gb-avatar--24 { width: 24px; height: 24px; font-size: 9.5px; }
|
|
59
|
-
.gb-avatar--32 { width: 32px; height: 32px; font-size: 11px; }
|
|
60
|
-
.gb-avatar--40 { width: 40px; height: 40px; font-size: 13px; }
|
|
61
|
-
.gb-avatar--56 { width: 56px; height: 56px; font-size: 18px; }
|
|
62
|
-
.gb-avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
63
|
-
/* One hue per tone; the disc and the initials are both mixed from it (light: dark initials on a pale
|
|
64
|
-
disc; dark: light initials on a deep disc). portal.test.tsx checks every tone clears 4.5:1 in both. */
|
|
65
|
-
.gb-avatar[data-tone] { background: color-mix(in srgb, var(--gb-tone) 16%, #ffffff); color: color-mix(in srgb, var(--gb-tone) 70%, #000000); }
|
|
66
|
-
.gb-avatar[data-tone="0"] { --gb-tone: #5661f5; }
|
|
67
|
-
.gb-avatar[data-tone="1"] { --gb-tone: #0a8f6a; }
|
|
68
|
-
.gb-avatar[data-tone="2"] { --gb-tone: #c26a04; }
|
|
69
|
-
.gb-avatar[data-tone="3"] { --gb-tone: #db2777; }
|
|
70
|
-
.gb-avatar[data-tone="4"] { --gb-tone: #0891b2; }
|
|
71
|
-
.gb-avatar[data-tone="5"] { --gb-tone: #7c3aed; }
|
|
72
|
-
.gb-avatar[data-tone="6"] { --gb-tone: #dc2626; }
|
|
73
|
-
.gb-avatar[data-tone="7"] { --gb-tone: #6b7280; }
|
|
74
|
-
|
|
75
|
-
/* ── Leaderboard: medals 1–3, then the rank; avatar, name, count; the leader's count in gold. Rows are
|
|
76
|
-
44px, so neighbours need no gap (§9.1). */
|
|
77
|
-
.gb-leaderboard { display: grid; gap: 6px; min-width: 0; }
|
|
78
|
-
.gb-leaderboard-title { margin: 0; display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 650; letter-spacing: .04em;
|
|
79
|
-
text-transform: uppercase; color: var(--gemboss-text-muted); }
|
|
80
|
-
.gb-leaderboard-caption { margin: -2px 0 2px; font-size: 12px; color: var(--gemboss-text-muted); }
|
|
81
|
-
.gb-leaderboard-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
|
|
82
|
-
.gb-leaderboard-link { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 6px; border-radius: 8px;
|
|
83
|
-
color: var(--gemboss-text); text-decoration: none; }
|
|
84
|
-
a.gb-leaderboard-link:hover { background: var(--gemboss-hover); }
|
|
85
|
-
a.gb-leaderboard-link:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
|
|
86
|
-
.gb-leaderboard-rank { width: 20px; flex: 0 0 auto; text-align: center; font-size: 15px; line-height: 1; }
|
|
87
|
-
.gb-leaderboard-rank.is-number { font-size: 12px; font-weight: 700; color: var(--gemboss-text-muted); font-variant-numeric: tabular-nums; }
|
|
88
|
-
.gb-leaderboard-name { flex: 1 1 auto; min-width: 0; font-size: 13px; font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
89
|
-
.gb-leaderboard-value { flex: 0 0 auto; font-size: 13px; font-weight: 650; color: var(--gemboss-text-muted); font-variant-numeric: tabular-nums; }
|
|
90
|
-
.gb-leaderboard-row.is-first .gb-leaderboard-value { color: #8a5a00; font-weight: 750; }
|
|
91
|
-
/* The viewer's own row and podium slot: the selected tint, never a colour of its own. */
|
|
92
|
-
.gb-leaderboard-row.is-current > .gb-leaderboard-link { background: var(--gemboss-active-bg); }
|
|
93
|
-
.gb-leaderboard-badge { flex: 0 1 auto; min-width: 0; overflow: hidden; }
|
|
94
|
-
.gb-leaderboard-all { justify-self: center; display: inline-flex; align-items: center; min-height: 24px; font-size: 12px; font-weight: 600;
|
|
95
|
-
color: var(--gemboss-text-muted); text-decoration: none; }
|
|
96
|
-
.gb-leaderboard-all:hover { color: var(--gemboss-text-strong); text-decoration: underline; }
|
|
97
|
-
|
|
98
|
-
/* ── Podium: second · first · third, the first raised. */
|
|
99
|
-
.gb-podium { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: end; }
|
|
100
|
-
.gb-podium-link { display: grid; justify-items: center; gap: 6px; padding: 14px 8px; border: 1px solid var(--gemboss-divider); border-radius: 12px;
|
|
101
|
-
background: var(--gemboss-surface); color: var(--gemboss-text); text-decoration: none; text-align: center; }
|
|
102
|
-
a.gb-podium-link:hover { background: var(--gemboss-hover); }
|
|
103
|
-
.gb-podium-slot { min-width: 0; }
|
|
104
|
-
/* A podium with fewer than three people keeps its place: second and third stay empty columns. */
|
|
105
|
-
.gb-podium-slot.is-empty { visibility: hidden; }
|
|
106
|
-
.gb-podium-slot--1 .gb-podium-link { padding-top: 22px; padding-bottom: 20px; border-color: color-mix(in srgb, #d4a017 45%, var(--gemboss-divider)); }
|
|
107
|
-
.gb-podium-slot--2 .gb-podium-link { padding-bottom: 12px; }
|
|
108
|
-
.gb-podium-slot--3 .gb-podium-link { padding-bottom: 8px; }
|
|
109
|
-
.gb-podium-medal { font-size: 22px; line-height: 1; }
|
|
110
|
-
.gb-podium-name { max-width: 100%; font-size: 13px; font-weight: 620; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
111
|
-
.gb-podium-value { font-size: 15px; font-weight: 750; font-variant-numeric: tabular-nums; }
|
|
112
|
-
.gb-podium-slot--1 .gb-podium-value { color: #8a5a00; }
|
|
113
|
-
.gb-podium-unit { font-size: 11px; font-weight: 500; color: var(--gemboss-text-muted); }
|
|
114
|
-
.gb-podium-slot.is-current .gb-podium-link { border-color: var(--gemboss-text-strong); background: var(--gemboss-active-bg); }
|
|
115
|
-
|
|
116
|
-
/* ── SearchDialog: the topbar's search button, and the popup (640 wide, near the top, like Featurebase's). */
|
|
117
|
-
.gb-search-trigger, .gb-theme-toggle { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex: 0 0 auto;
|
|
118
|
-
border: 0; border-radius: 8px; background: transparent; color: var(--gemboss-text-muted); cursor: pointer; }
|
|
119
|
-
.gb-search-trigger:hover, .gb-theme-toggle:hover { background: var(--gemboss-hover); color: var(--gemboss-text-strong); }
|
|
120
|
-
.gb-search-trigger:focus-visible, .gb-theme-toggle:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
|
|
121
|
-
@media (pointer: coarse), (max-width: 760px) { .gb-search-trigger, .gb-theme-toggle { width: 44px; height: 44px; } }
|
|
122
|
-
dialog.ui-modal.gb-search { width: min(640px, 100%); margin-top: 8vh; max-height: min(560px, calc(100dvh - 16vh)); }
|
|
123
|
-
.gb-search-box { position: relative; display: flex; align-items: center; border-bottom: 1px solid var(--ui-border); }
|
|
124
|
-
.gb-search-icon { position: absolute; left: 18px; color: var(--ui-text-muted); pointer-events: none; }
|
|
125
|
-
.gb-search-input { flex: 1 1 auto; min-width: 0; height: 52px; padding: 0 18px 0 46px; border: 0; background: transparent; color: var(--ui-text);
|
|
126
|
-
font: inherit; font-size: 15px; outline: none; }
|
|
127
|
-
.gb-search-input::-webkit-search-cancel-button { display: none; }
|
|
128
|
-
.gb-search-results { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 8px; }
|
|
129
|
-
.gb-search-section + .gb-search-section { margin-top: 8px; }
|
|
130
|
-
.gb-search-section-title { padding: 6px 10px; font-size: 12px; font-weight: 600; color: var(--ui-text-muted); }
|
|
131
|
-
.gb-search-item { display: grid; gap: 2px; min-height: 44px; align-content: center; padding: 6px 10px; border-radius: 8px; color: var(--ui-text); text-decoration: none; }
|
|
132
|
-
.gb-search-item.is-active { background: var(--ui-surface-2); }
|
|
133
|
-
.gb-search-item-title { font-size: 14px; font-weight: 520; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
134
|
-
.gb-search-item-meta { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--ui-text-muted); }
|
|
135
|
-
.gb-search-empty { margin: 0; padding: 18px 18px 0; font-size: 13px; color: var(--ui-text-muted); }
|
|
136
|
-
.gb-search-empty:empty { padding: 0; }
|
|
137
|
-
.gb-search-foot { display: flex; gap: 16px; justify-content: flex-end; padding: 8px 14px; border-top: 1px solid var(--ui-border);
|
|
138
|
-
font-size: 12px; color: var(--ui-text-muted); }
|
|
139
|
-
@media (max-width: 760px) { dialog.ui-modal.gb-search { margin-top: 12px; } .gb-search-foot { display: none; } }
|
|
1
|
+
.gb-topnav{display:flex;align-items:stretch;gap:4px;min-width:0;height:100%;margin-left:12px;padding-left:12px;border-left:1px solid var(--gemboss-divider);overflow-x:auto;scrollbar-width:none}.gb-topnav::-webkit-scrollbar{display:none}.gb-topnav-link{position:relative;display:inline-flex;align-items:center;align-self:stretch;flex:0 0 auto;min-height:36px;padding:0 10px;border-radius:8px;color:var(--gemboss-text-muted);font-size:14px;font-weight:520;text-decoration:none;white-space:nowrap}.gb-topnav-link:hover{color:var(--gemboss-text-strong);background:var(--gemboss-hover)}.gb-topnav-link:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}.gb-topnav-link[aria-current=page]{color:var(--gemboss-text-strong);font-weight:640}.gb-topnav-link[aria-current=page]:after{content:"";position:absolute;left:10px;right:10px;bottom:-1px;height:2px;border-radius:2px;background:var(--gemboss-text-strong)}@media (pointer: coarse){.gb-topnav-link{min-height:44px}}.gemboss-platform-topbar:has(.gb-topnav):has(>.gemboss-topbar-center:empty){grid-template-columns:minmax(0,1fr) auto}.gemboss-platform-topbar:has(.gb-topnav)>.gemboss-topbar-center:empty{display:none}.gemboss-topbar-start:has(.gb-topnav){min-width:0;height:100%}.gemboss-topbar-start:has(.gb-topnav)>.gb-brand{flex:0 0 auto}@media (max-width: 760px){.gb-topnav{margin-left:8px;padding-left:8px}.gb-topnav-link{min-height:44px;padding:0 8px}}.gb-topnav-row{display:flex;align-items:stretch;gap:4px;min-width:0;height:100%}.gb-topnav-fold{display:none;position:relative;align-self:stretch}.gb-topnav-fold>summary{list-style:none;cursor:pointer;gap:4px;height:100%}.gb-topnav-fold>summary::-webkit-details-marker{display:none}.gb-topnav-fold-btn[aria-current=page]:after{display:none}.gb-topnav-chevron{flex:0 0 auto;transition:transform .15s ease}.gb-topnav-fold[open] .gb-topnav-chevron{transform:rotate(180deg)}.gb-topnav-pop{position:fixed;top:56px;left:8px;min-width:200px;padding:6px;display:grid;gap:2px;z-index:60}.gb-topnav-item{display:flex;align-items:center;min-height:44px;padding:0 12px;border-radius:8px;color:var(--gemboss-text);font-size:14px;font-weight:520;text-decoration:none}.gb-topnav-item:hover{background:var(--gemboss-hover)}.gb-topnav-item:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}.gb-topnav-item[aria-current=page]{color:var(--gemboss-active-fg);background:var(--gemboss-active-bg);font-weight:640}@media (max-width: 600px){.gb-topnav{overflow:visible}.gb-topnav-row{display:none}.gb-topnav-fold{display:flex}}@media (prefers-reduced-motion: reduce){.gb-topnav-chevron{transition:none}}.gb-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.gb-avatar{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;border-radius:50%;overflow:hidden;font-weight:700;line-height:1;letter-spacing:.01em;user-select:none}.gb-avatar--24{width:24px;height:24px;font-size:9.5px}.gb-avatar--32{width:32px;height:32px;font-size:11px}.gb-avatar--40{width:40px;height:40px;font-size:13px}.gb-avatar--56{width:56px;height:56px;font-size:18px}.gb-avatar-img{width:100%;height:100%;object-fit:cover;display:block}.gb-avatar[data-tone]{background:color-mix(in srgb,var(--gb-tone) 16%,#ffffff);color:color-mix(in srgb,var(--gb-tone) 70%,#000000)}.gb-avatar[data-tone="0"]{--gb-tone: #5661f5}.gb-avatar[data-tone="1"]{--gb-tone: #0a8f6a}.gb-avatar[data-tone="2"]{--gb-tone: #c26a04}.gb-avatar[data-tone="3"]{--gb-tone: #db2777}.gb-avatar[data-tone="4"]{--gb-tone: #0891b2}.gb-avatar[data-tone="5"]{--gb-tone: #7c3aed}.gb-avatar[data-tone="6"]{--gb-tone: #dc2626}.gb-avatar[data-tone="7"]{--gb-tone: #6b7280}.gb-leaderboard{display:grid;gap:6px;min-width:0}.gb-leaderboard-title{margin:0;display:flex;align-items:center;gap:6px;font-size:12px;font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--gemboss-text-muted)}.gb-leaderboard-caption{margin:-2px 0 2px;font-size:12px;color:var(--gemboss-text-muted)}.gb-leaderboard-list{list-style:none;margin:0;padding:0;display:grid;gap:0}.gb-leaderboard-link{display:flex;align-items:center;gap:10px;min-height:44px;padding:0 6px;border-radius:8px;color:var(--gemboss-text);text-decoration:none}a.gb-leaderboard-link:hover{background:var(--gemboss-hover)}a.gb-leaderboard-link:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}.gb-leaderboard-rank{width:20px;flex:0 0 auto;text-align:center;font-size:15px;line-height:1}.gb-leaderboard-rank.is-number{font-size:12px;font-weight:700;color:var(--gemboss-text-muted);font-variant-numeric:tabular-nums}.gb-leaderboard-name{flex:1 1 auto;min-width:0;font-size:13px;font-weight:560;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gb-leaderboard-value{flex:0 0 auto;font-size:13px;font-weight:650;color:var(--gemboss-text-muted);font-variant-numeric:tabular-nums}.gb-leaderboard-row.is-first .gb-leaderboard-value{color:#8a5a00;font-weight:750}.gb-leaderboard-row.is-current>.gb-leaderboard-link{background:var(--gemboss-active-bg)}.gb-leaderboard-badge{flex:0 1 auto;min-width:0;overflow:hidden}.gb-leaderboard-all{justify-self:center;display:inline-flex;align-items:center;min-height:24px;font-size:12px;font-weight:600;color:var(--gemboss-text-muted);text-decoration:none}.gb-leaderboard-all:hover{color:var(--gemboss-text-strong);text-decoration:underline}.gb-podium{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;align-items:end}.gb-podium-link{display:grid;justify-items:center;gap:6px;padding:14px 8px;border:1px solid var(--gemboss-divider);border-radius:12px;background:var(--gemboss-surface);color:var(--gemboss-text);text-decoration:none;text-align:center}a.gb-podium-link:hover{background:var(--gemboss-hover)}.gb-podium-slot{min-width:0}.gb-podium-slot.is-empty{visibility:hidden}.gb-podium-slot--1 .gb-podium-link{padding-top:22px;padding-bottom:20px;border-color:color-mix(in srgb,#d4a017 45%,var(--gemboss-divider))}.gb-podium-slot--2 .gb-podium-link{padding-bottom:12px}.gb-podium-slot--3 .gb-podium-link{padding-bottom:8px}.gb-podium-medal{font-size:22px;line-height:1}.gb-podium-name{max-width:100%;font-size:13px;font-weight:620;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gb-podium-value{font-size:15px;font-weight:750;font-variant-numeric:tabular-nums}.gb-podium-slot--1 .gb-podium-value{color:#8a5a00}.gb-podium-unit{font-size:11px;font-weight:500;color:var(--gemboss-text-muted)}.gb-podium-slot.is-current .gb-podium-link{border-color:var(--gemboss-text-strong);background:var(--gemboss-active-bg)}.gb-search-trigger,.gb-theme-toggle{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;flex:0 0 auto;border:0;border-radius:8px;background:transparent;color:var(--gemboss-text-muted);cursor:pointer}.gb-search-trigger:hover,.gb-theme-toggle:hover{background:var(--gemboss-hover);color:var(--gemboss-text-strong)}.gb-search-trigger:focus-visible,.gb-theme-toggle:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}@media (pointer: coarse),(max-width: 760px){.gb-search-trigger,.gb-theme-toggle{width:44px;height:44px}}dialog.ui-modal.gb-search{width:min(640px,100%);margin-top:8vh;max-height:min(560px,calc(100dvh - 16vh))}.gb-search-box{position:relative;display:flex;align-items:center;border-bottom:1px solid var(--ui-border)}.gb-search-icon{position:absolute;left:18px;color:var(--ui-text-muted);pointer-events:none}.gb-search-input{flex:1 1 auto;min-width:0;height:52px;padding:0 18px 0 46px;border:0;background:transparent;color:var(--ui-text);font:inherit;font-size:15px;outline:none}.gb-search-input::-webkit-search-cancel-button{display:none}.gb-search-results{flex:1 1 auto;min-height:0;overflow:auto;padding:8px}.gb-search-section+.gb-search-section{margin-top:8px}.gb-search-section-title{padding:6px 10px;font-size:12px;font-weight:600;color:var(--ui-text-muted)}.gb-search-item{display:grid;gap:2px;min-height:44px;align-content:center;padding:6px 10px;border-radius:8px;color:var(--ui-text);text-decoration:none}.gb-search-item.is-active{background:var(--ui-surface-2)}.gb-search-item-title{font-size:14px;font-weight:520;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gb-search-item-meta{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--ui-text-muted)}.gb-search-empty{margin:0;padding:18px 18px 0;font-size:13px;color:var(--ui-text-muted)}.gb-search-empty:empty{padding:0}.gb-search-foot{display:flex;gap:16px;justify-content:flex-end;padding:8px 14px;border-top:1px solid var(--ui-border);font-size:12px;color:var(--ui-text-muted)}@media (max-width: 760px){dialog.ui-modal.gb-search{margin-top:12px}.gb-search-foot{display:none}}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactions: the emoji a person can put on a message, a comment or a post — one set and one look for the fleet
|
|
3
|
+
* (@gemboss/ui, Chris 06/10/2026: "reaction các thứ comment nên chuẩn hoá một kiểu, bên gemcare cũng có, nên đưa
|
|
4
|
+
* vào gemboss ui"). The set is GemCare's team chat set, moved here unchanged: it was built from Lark's, with search words
|
|
5
|
+
* per emoji. A server validates against `isReactionEmoji` and refuses anything else, so a client with a stale copy can
|
|
6
|
+
* only fail a click, never store a new kind of emoji.
|
|
7
|
+
*
|
|
8
|
+
* Plain TypeScript (no React): a server, a plain-HTML surface and the React component all import it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** [group name, [emoji, search words][]][] — the picker draws the groups in this order. */
|
|
12
|
+
export const REACTION_GROUPS: ReadonlyArray<readonly [string, ReadonlyArray<readonly [string, string]>]> = [
|
|
13
|
+
["Smileys", [["😀", "grin smile happy"], ["😃", "smile happy"], ["😄", "smile laugh happy"], ["😁", "grin beam"], ["😆", "laugh squint"], ["😅", "sweat smile relief"], ["😂", "joy tears laugh lol"], ["🤣", "rofl rolling laugh"], ["😊", "blush smile"], ["😇", "innocent halo"], ["🙂", "slight smile ok"], ["😉", "wink"], ["😍", "heart eyes love"], ["🥰", "love hearts"], ["😘", "kiss"], ["😋", "yum tasty"], ["😜", "wink tongue"], ["🤪", "zany crazy"], ["🤗", "hug"], ["🤭", "oops giggle"], ["🤔", "think hmm"], ["🤨", "raised eyebrow doubt"], ["😐", "neutral"], ["😶", "no words"], ["🙄", "eye roll"], ["😏", "smirk"], ["😬", "grimace awkward"], ["😌", "relieved"], ["😴", "sleep tired"], ["😷", "mask sick"], ["🤯", "mind blown"], ["🥳", "party celebrate"], ["😎", "cool sunglasses"], ["🤓", "nerd"], ["😕", "confused"], ["😟", "worried"], ["😮", "wow surprised"], ["😲", "astonished"], ["😳", "flushed embarrassed"], ["🥺", "pleading please"], ["😢", "cry sad"], ["😭", "sob crying"], ["😱", "scream shock"], ["😤", "huff frustrated"], ["😡", "angry mad"], ["🤬", "swear"], ["💀", "dead skull"], ["🫠", "melting"], ["🫡", "salute"]]],
|
|
14
|
+
["Hands", [["👍", "like thumbs up yes ok"], ["👎", "dislike thumbs down no"], ["👌", "ok perfect"], ["✌️", "peace victory"], ["🤞", "fingers crossed luck"], ["🤟", "love you"], ["🤘", "rock"], ["👏", "clap applause"], ["🙌", "raise hands hooray"], ["👐", "open hands"], ["🤝", "handshake deal"], ["🙏", "thanks please pray"], ["💪", "strong muscle"], ["👀", "eyes looking"], ["🫶", "heart hands"], ["✍️", "writing"], ["👋", "wave hi bye"], ["☝️", "point up"], ["👇", "point down"], ["👉", "point right"], ["👈", "point left"], ["🤙", "call me"], ["🖐️", "stop hand"], ["🤌", "pinched"]]],
|
|
15
|
+
["Marks", [["❤️", "heart love red"], ["🧡", "orange heart"], ["💛", "yellow heart"], ["💚", "green heart"], ["💙", "blue heart"], ["💜", "purple heart"], ["🖤", "black heart"], ["💯", "hundred perfect"], ["✅", "done check yes"], ["☑️", "checked box"], ["✔️", "check mark"], ["❌", "no cross wrong"], ["❗", "important exclamation"], ["❓", "question"], ["⚠️", "warning"], ["🔥", "fire hot lit"], ["⭐", "star"], ["✨", "sparkles new"], ["💡", "idea"], ["📌", "pin"], ["🚀", "rocket ship launch"], ["🎯", "target goal"], ["🆗", "ok button"], ["🆕", "new"], ["🔴", "red circle urgent"], ["🟢", "green circle"], ["🟡", "yellow circle"], ["➕", "plus one add"]]],
|
|
16
|
+
["Work", [["🎉", "party tada celebrate"], ["🎊", "confetti"], ["🏆", "trophy win"], ["🥇", "gold medal first"], ["🎁", "gift"], ["☕", "coffee"], ["🍻", "cheers beer"], ["🍕", "pizza"], ["📈", "chart up growth"], ["📉", "chart down"], ["💰", "money"], ["🛒", "cart shop"], ["📦", "package order shipping"], ["🧾", "receipt invoice"], ["📝", "note memo"], ["📅", "calendar date"], ["⏰", "alarm time"], ["⏳", "waiting hourglass"], ["🔧", "fix wrench"], ["🐛", "bug"], ["🧪", "test"], ["🔍", "search look"], ["💬", "comment chat"], ["📞", "call phone"], ["📧", "email"], ["🔒", "lock private"], ["🔗", "link"], ["🖼️", "image picture"], ["💻", "laptop computer"], ["📱", "phone mobile"], ["🙈", "see no evil oops"], ["🙉", "hear no evil"], ["🤖", "robot bot ai"], ["🧠", "brain"], ["👑", "crown vip"], ["💎", "gem diamond"]]],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Every emoji a person may react with, in picker order. */
|
|
20
|
+
export const REACTION_EMOJI: readonly string[] = REACTION_GROUPS.flatMap(([, items]) => items.map(([e]) => e));
|
|
21
|
+
const KEYS = new Map<string, string>(REACTION_GROUPS.flatMap(([, items]) => items.map(([e, k]) => [e, k] as [string, string])));
|
|
22
|
+
|
|
23
|
+
/** The quick pair shown on hover next to "Add reaction" (GemCare's 👍 Like and ✅ Done). */
|
|
24
|
+
export const QUICK_REACTIONS: readonly string[] = ["👍", "✅"];
|
|
25
|
+
|
|
26
|
+
export function isReactionEmoji(e: unknown): e is string {
|
|
27
|
+
return typeof e === "string" && KEYS.has(e);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The search words of an emoji ("" for one outside the set). */
|
|
31
|
+
export function reactionWords(e: string): string {
|
|
32
|
+
return KEYS.get(e) ?? "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Emoji whose search words contain the query (any case); all of them for an empty query. */
|
|
36
|
+
export function searchReactions(query: string): string[] {
|
|
37
|
+
const t = query.trim().toLowerCase();
|
|
38
|
+
return t ? REACTION_EMOJI.filter((e) => KEYS.get(e)!.includes(t)) : [...REACTION_EMOJI];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One emoji on one thing: who put it there. `mine` is the viewer's own. */
|
|
42
|
+
export type Reaction = { emoji: string; names: string[]; mine: boolean };
|
|
43
|
+
|
|
44
|
+
/** The chip's words: the names, not a count ("👍 Linh, Quan" says who agreed; Lark draws it so), two then "+n". */
|
|
45
|
+
export function reactionLabel(names: readonly string[]): string {
|
|
46
|
+
return names.length > 2 ? `${names.slice(0, 2).join(", ")} +${names.length - 2}` : names.join(", ");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The guess drawn at once on a click, before the server answers (its answer replaces it): add yourself, or take yourself
|
|
50
|
+
* off and drop the chip when you were the last. */
|
|
51
|
+
export function toggleReaction(list: readonly Reaction[], emoji: string, myName = "You"): Reaction[] {
|
|
52
|
+
const at = list.findIndex((r) => r.emoji === emoji);
|
|
53
|
+
if (at < 0) return [...list, { emoji, names: [myName], mine: true }];
|
|
54
|
+
const r = list[at];
|
|
55
|
+
if (!r.mine) return list.map((x, i) => (i === at ? { ...x, names: [...x.names, myName], mine: true } : x));
|
|
56
|
+
const idx = r.names.lastIndexOf(myName);
|
|
57
|
+
const left = idx >= 0 ? r.names.filter((_, i) => i !== idx) : r.names.slice(0, -1);
|
|
58
|
+
return left.length ? list.map((x, i) => (i === at ? { ...x, names: left, mine: false } : x)) : list.filter((_, i) => i !== at);
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.ui-reactions{display:grid;gap:4px;min-width:0}.ui-reactions__row{display:flex;flex-wrap:wrap;align-items:center;gap:0 8px;min-width:0}.ui-reaction{display:inline-flex;align-items:center;min-height:44px;min-width:44px;padding:9px 0;border:0;background:none;cursor:pointer;font:inherit;color:inherit;max-width:100%}.ui-reaction__emoji,.ui-reaction__names{display:inline-flex;align-items:center;height:26px;box-sizing:border-box;border:1px solid var(--ui-border);background:var(--ui-surface)}.ui-reaction__emoji{padding:0 4px 0 6px;border-right:0;border-radius:999px 0 0 999px;font-size:14px;line-height:1}.ui-reaction__names{padding:0 8px 0 4px;border-left:0;border-radius:0 999px 999px 0;min-width:0;max-width:180px;font-size:var(--ui-fs-caption);color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ui-reaction:hover :is(.ui-reaction__emoji,.ui-reaction__names){background:var(--ui-surface-2)}.ui-reaction:focus-visible{outline:none}.ui-reaction:focus-visible :is(.ui-reaction__emoji,.ui-reaction__names){outline:2px solid var(--ui-focus);outline-offset:1px}.ui-reaction.is-mine :is(.ui-reaction__emoji,.ui-reaction__names){border-color:var(--ui-accent);background:var(--ui-accent-soft);color:var(--ui-accent-strong)}.ui-reaction-add{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;padding:0;border:0;border-radius:var(--ui-radius-md);background:none;color:var(--ui-text-muted);cursor:pointer}.ui-reaction-add:hover,.ui-reaction-add[aria-expanded=true]{background:var(--ui-surface-2);color:inherit}.ui-reaction-add:focus-visible{outline:2px solid var(--ui-focus);outline-offset:-2px}.ui-reaction-picker{display:grid;gap:6px;width:420px;max-width:100%;box-sizing:border-box;padding:8px;border:1px solid var(--ui-border);border-radius:var(--ui-radius-md);background:var(--ui-surface);box-shadow:var(--ui-card-shadow)}.ui-reaction-picker__search{width:100%;box-sizing:border-box}.ui-reaction-picker__body{max-height:300px;overflow-y:auto;overscroll-behavior:contain;display:grid;gap:2px}.ui-reaction-picker__head{margin:6px 4px 2px;font-size:var(--ui-fs-caption);color:var(--ui-text-muted)}.ui-reaction-picker__grid{display:grid;grid-template-columns:repeat(auto-fill,44px);justify-content:start}.ui-reaction-picker__btn{width:44px;height:44px;padding:0;border:1px solid transparent;border-radius:var(--ui-radius-sm);background:transparent;cursor:pointer;font-size:22px;line-height:1}.ui-reaction-picker__btn:hover{background:var(--ui-surface-2)}.ui-reaction-picker__btn:focus-visible{outline:2px solid var(--ui-focus);outline-offset:-2px}.ui-reaction-picker__btn[aria-pressed=true]{border-color:var(--ui-accent);background:var(--ui-accent-soft)}.ui-reaction-picker__empty{margin:8px 4px;color:var(--ui-text-muted)}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* Reactions — @gemboss/ui (unreleased). One look and one behaviour for emoji reactions everywhere in the fleet: GemCare's team
|
|
4
|
+
* chat (where this was first built, `public/assets/inbox.js`), GemFeedback's requests and comments, and whatever comes
|
|
5
|
+
* next. The data (which emoji, their search words, the optimistic toggle) is `./reactions-data`; this file is the React
|
|
6
|
+
* face of it; `reactions.css` (its own entry: `import "@gemboss/ui/reactions.css"`) is the look, and a plain-HTML surface writes the same classes (README "Reactions").
|
|
7
|
+
*
|
|
8
|
+
* What it draws:
|
|
9
|
+
* - the chips already put: emoji + the NAMES of who put it ("👍 Linh, Quan +2"; Lark's way: names say who agreed, a
|
|
10
|
+
* count does not), the viewer's own outlined and filled (not only coloured: two chips that differ only in hue look the
|
|
11
|
+
* same to a colour-blind reader). No chips, no row.
|
|
12
|
+
* - "Add reaction": opens the picker IN FLOW under the row (not floating: a layer in a scrolling column has to fight the
|
|
13
|
+
* bottom edge; a row in flow is scrolled to by the browser). Frequently used first (this browser's, else the quick
|
|
14
|
+
* pair), then the groups; search by words; arrows move in the grid, Enter in the search picks the first, Escape closes
|
|
15
|
+
* and gives focus back to the button.
|
|
16
|
+
*
|
|
17
|
+
* Each target is 44px (a control, CLAUDE.md §9.1) while the visible pill is 26px: transparent padding carries the rest,
|
|
18
|
+
* so a message with reactions does not grow a whole line.
|
|
19
|
+
*/
|
|
20
|
+
import React, { useEffect, useId, useRef, useState } from "react";
|
|
21
|
+
import { Icon } from "./icons";
|
|
22
|
+
import { QUICK_REACTIONS, REACTION_GROUPS, reactionLabel, reactionWords, searchReactions, type Reaction } from "./reactions-data";
|
|
23
|
+
|
|
24
|
+
const RECENT_KEY = "gemboss-ui:reactions-recent";
|
|
25
|
+
const RECENT_MAX = 9;
|
|
26
|
+
|
|
27
|
+
function readRecent(): string[] {
|
|
28
|
+
try {
|
|
29
|
+
const v = JSON.parse(globalThis.localStorage?.getItem(RECENT_KEY) ?? "[]");
|
|
30
|
+
return Array.isArray(v) ? v.filter((e): e is string => typeof e === "string" && reactionWords(e) !== "").slice(0, RECENT_MAX) : [];
|
|
31
|
+
} catch { return []; }
|
|
32
|
+
}
|
|
33
|
+
function rememberRecent(emoji: string): void {
|
|
34
|
+
try {
|
|
35
|
+
const next = [emoji, ...readRecent().filter((e) => e !== emoji)].slice(0, RECENT_MAX);
|
|
36
|
+
globalThis.localStorage?.setItem(RECENT_KEY, JSON.stringify(next));
|
|
37
|
+
} catch { /* private window, storage off: the picker just shows the quick pair */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ReactionsProps = {
|
|
41
|
+
items: readonly Reaction[];
|
|
42
|
+
/** Put or take back the viewer's emoji. The caller decides add or remove from its own state (a click on a chip that is
|
|
43
|
+
* `mine` takes it back); `toggleReaction` is the optimistic guess to draw before the server answers. */
|
|
44
|
+
onToggle?: (emoji: string) => void;
|
|
45
|
+
/** Who or what is reacted to, for screen readers: "the comment from Linh". */
|
|
46
|
+
target?: string;
|
|
47
|
+
/** Show "Add reaction" (default true). A signed-out reader sees the chips only. */
|
|
48
|
+
canReact?: boolean;
|
|
49
|
+
/** The text a signed-out reader's click should lead to instead (e.g. open sign-in); chips are buttons either way. */
|
|
50
|
+
onSignIn?: () => void;
|
|
51
|
+
className?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function Reactions({ items, onToggle, target = "this", canReact = true, onSignIn, className }: ReactionsProps) {
|
|
55
|
+
const [open, setOpen] = useState(false);
|
|
56
|
+
const addRef = useRef<HTMLButtonElement>(null);
|
|
57
|
+
const pickerId = useId();
|
|
58
|
+
const act = (emoji: string) => {
|
|
59
|
+
if (!canReact) { onSignIn?.(); return; }
|
|
60
|
+
if (!items.some((r) => r.emoji === emoji && r.mine)) rememberRecent(emoji);
|
|
61
|
+
onToggle?.(emoji);
|
|
62
|
+
};
|
|
63
|
+
if (!items.length && !canReact) return null;
|
|
64
|
+
return (
|
|
65
|
+
<div className={["ui-reactions", className].filter(Boolean).join(" ")}>
|
|
66
|
+
<div className="ui-reactions__row">
|
|
67
|
+
{items.map((r) => (
|
|
68
|
+
<button key={r.emoji} type="button" className={`ui-reaction${r.mine ? " is-mine" : ""}`} aria-pressed={r.mine}
|
|
69
|
+
title={r.names.join(", ")} aria-label={`${r.emoji}, ${r.names.join(", ")}. ${r.mine ? "Remove yours" : "Add yours"}`}
|
|
70
|
+
onClick={() => act(r.emoji)}>
|
|
71
|
+
<span className="ui-reaction__emoji">{r.emoji}</span>
|
|
72
|
+
<span className="ui-reaction__names">{reactionLabel(r.names)}</span>
|
|
73
|
+
</button>
|
|
74
|
+
))}
|
|
75
|
+
{canReact ? (
|
|
76
|
+
<button ref={addRef} type="button" className="ui-reaction-add" title="Add reaction" aria-label={`Add a reaction to ${target}`}
|
|
77
|
+
aria-expanded={open} aria-controls={open ? pickerId : undefined} onClick={() => setOpen((o) => !o)}>
|
|
78
|
+
<Icon name="reaction" size={18} />
|
|
79
|
+
</button>
|
|
80
|
+
) : null}
|
|
81
|
+
</div>
|
|
82
|
+
{open ? (
|
|
83
|
+
<ReactionPicker id={pickerId} items={items}
|
|
84
|
+
onPick={(emoji) => { setOpen(false); act(emoji); addRef.current?.focus(); }}
|
|
85
|
+
onClose={() => { setOpen(false); addRef.current?.focus(); }} />
|
|
86
|
+
) : null}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function ReactionPicker({ items, onPick, onClose, id }: { items: readonly Reaction[]; onPick: (emoji: string) => void; onClose: () => void; id?: string }) {
|
|
92
|
+
const [q, setQ] = useState("");
|
|
93
|
+
const [recent, setRecent] = useState<readonly string[]>(QUICK_REACTIONS);
|
|
94
|
+
const search = useRef<HTMLInputElement>(null);
|
|
95
|
+
const body = useRef<HTMLDivElement>(null);
|
|
96
|
+
// Storage is read after mount: the server render and the first client render must draw the same picker.
|
|
97
|
+
useEffect(() => { const r = readRecent(); if (r.length) setRecent(r); search.current?.focus(); }, []);
|
|
98
|
+
const mine = (e: string) => items.some((r) => r.emoji === e && r.mine);
|
|
99
|
+
const btn = (e: string) => (
|
|
100
|
+
<button key={e} type="button" className="ui-reaction-picker__btn" title={reactionWords(e).split(" ")[0] || e}
|
|
101
|
+
aria-label={`${mine(e) ? "Remove" : "Add"} ${e} ${reactionWords(e)}`.trim()} aria-pressed={mine(e)} onClick={() => onPick(e)}>{e}</button>
|
|
102
|
+
);
|
|
103
|
+
const grid = (list: readonly string[]) => <div className="ui-reaction-picker__grid">{list.map(btn)}</div>;
|
|
104
|
+
const found = q.trim() ? searchReactions(q) : null;
|
|
105
|
+
const onKeyDown = (ev: React.KeyboardEvent) => {
|
|
106
|
+
if (ev.key === "Escape") { ev.preventDefault(); ev.stopPropagation(); onClose(); return; }
|
|
107
|
+
const all = [...(body.current?.querySelectorAll<HTMLButtonElement>(".ui-reaction-picker__btn") ?? [])];
|
|
108
|
+
if (ev.target === search.current) {
|
|
109
|
+
if (ev.key === "Enter") { ev.preventDefault(); all[0]?.click(); }
|
|
110
|
+
if (ev.key === "ArrowDown") { ev.preventDefault(); all[0]?.focus(); }
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const at = all.indexOf(document.activeElement as HTMLButtonElement);
|
|
114
|
+
if (at < 0) return;
|
|
115
|
+
const gridEl = body.current?.querySelector(".ui-reaction-picker__grid");
|
|
116
|
+
const cols = Math.max(1, Math.round((gridEl?.getBoundingClientRect().width ?? 44) / 44));
|
|
117
|
+
const step = ({ ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols } as Record<string, number>)[ev.key];
|
|
118
|
+
if (!step) return;
|
|
119
|
+
ev.preventDefault();
|
|
120
|
+
if (at + step < 0) { search.current?.focus(); return; }
|
|
121
|
+
all[Math.min(all.length - 1, at + step)]?.focus();
|
|
122
|
+
};
|
|
123
|
+
return (
|
|
124
|
+
<div id={id} className="ui-reaction-picker" role="group" aria-label="Pick a reaction" onKeyDown={onKeyDown}>
|
|
125
|
+
<input ref={search} type="search" className="gemboss-field ui-reaction-picker__search" placeholder="Search emoji" aria-label="Search emoji"
|
|
126
|
+
autoComplete="off" value={q} onChange={(e) => setQ(e.target.value)} />
|
|
127
|
+
<div ref={body} className="ui-reaction-picker__body">
|
|
128
|
+
{found ? (found.length ? grid(found) : <p className="ui-reaction-picker__empty">No emoji matches.</p>) : (
|
|
129
|
+
<>
|
|
130
|
+
<p className="ui-reaction-picker__head">Frequently used</p>{grid(recent)}
|
|
131
|
+
{REACTION_GROUPS.map(([name, list]) => (
|
|
132
|
+
<React.Fragment key={name}><p className="ui-reaction-picker__head">{name}</p>{grid(list.map(([e]) => e))}</React.Fragment>
|
|
133
|
+
))}
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* SelectableResourceList (0.16.0): a Resource list whose items can be selected and acted on in bulk, with the IndexTable's
|
|
4
|
+
* rules (README "Collections"): a checkbox per item, first in its media slot (Polaris ResourceItem's place); Shift+click
|
|
5
|
+
* takes the range from the last box clicked; Escape clears while focus is in the list (an open ⋯ menu or dialog takes it
|
|
6
|
+
* first); the bulk actions get the selected ids in the list's order. The whole row still opens the item (the title's
|
|
7
|
+
* link covers it, no script); a click on the checkbox only selects.
|
|
8
|
+
*
|
|
9
|
+
* One difference from IndexTable, on purpose: the bar is ALWAYS there, above the list ("0 selected", actions disabled).
|
|
10
|
+
* A table has a header for the bar to cover; a list has none, so a bar that appears on the first check pushes every row
|
|
11
|
+
* down under the finger that is choosing them (talent-radar /pool measured it: 74px at 1280, 194px at 390, 03/10/2026).
|
|
12
|
+
*
|
|
13
|
+
* Its own client module, like ./index-table (the selection rules and the bar are ./selection, shared with it):
|
|
14
|
+
* `ResourceList` (./collections) stays plain, so a server component renders it, and nothing the admin imports pulls
|
|
15
|
+
* this in. Items are plain data plus rendered nodes, so a server page can render
|
|
16
|
+
* it too; `onAction` and `onSelectionChange` need a client parent.
|
|
17
|
+
*/
|
|
18
|
+
import React from "react";
|
|
19
|
+
import { CollectionSkeleton, ResourceItem, ResourceList, type ResourceItemProps, type ResourceListProps } from "./collections";
|
|
20
|
+
import { BulkBar, useSelection, type BulkAction } from "./selection";
|
|
21
|
+
|
|
22
|
+
/** An item: ResourceItem's props, `id` required. The id is the selection key only; it is not put on the `li` (two lists
|
|
23
|
+
* of the same records on one page, or an id like "main", would collide in the DOM). */
|
|
24
|
+
export type SelectableResourceItem = Omit<ResourceItemProps, "selected" | "check" | "id"> & { id: string };
|
|
25
|
+
|
|
26
|
+
export type SelectableResourceListProps = Omit<ResourceListProps, "children"> & {
|
|
27
|
+
items: SelectableResourceItem[];
|
|
28
|
+
/** What to do with the selection. The checkboxes exist only with at least one (a checkbox that does nothing is inert). */
|
|
29
|
+
bulkActions: BulkAction[];
|
|
30
|
+
/** Controlled selection; leave both out and the list keeps it. */
|
|
31
|
+
selected?: string[];
|
|
32
|
+
onSelectionChange?: (ids: string[]) => void;
|
|
33
|
+
/** "3 selected": `(n) => …`. Shown at 0 too. */
|
|
34
|
+
selectedLabel?: (n: number) => React.ReactNode;
|
|
35
|
+
selectAllLabel?: string;
|
|
36
|
+
/** The link element for every item's title (a Next `Link` from a client module). An item's own `linkAs` wins. */
|
|
37
|
+
linkAs?: React.ElementType;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function SelectableResourceList({ items, bulkActions, selected, onSelectionChange, selectedLabel = (n) => `${n} selected`,
|
|
41
|
+
selectAllLabel = "Select all", linkAs, label, flush, state = "ready", stateSlot, loadingRows, loadingMedia = "thumbnail", className }: SelectableResourceListProps) {
|
|
42
|
+
const selectable = bulkActions.length > 0;
|
|
43
|
+
const showItems = state === "ready" || state === "busy";
|
|
44
|
+
const ids = showItems ? items.map((it) => it.id) : [];
|
|
45
|
+
// Own or controlled ids, Shift range, select-all, Escape (./selection: the same rules as IndexTable).
|
|
46
|
+
const { rootRef, selSet, chosen, all, some, toggle, toggleAll } = useSelection<HTMLDivElement>(ids, selected, onSelectionChange, selectable);
|
|
47
|
+
|
|
48
|
+
const list = state === "loading"
|
|
49
|
+
// The skeleton keeps a box's place, so the rows do not move right when they arrive.
|
|
50
|
+
? <CollectionSkeleton pattern="list" rows={loadingRows} media={loadingMedia} label={label} flush={flush} check={selectable} />
|
|
51
|
+
: <ResourceList label={label} flush={flush} state={state} stateSlot={stateSlot} className={className}>
|
|
52
|
+
{items.map(({ id, ...it }, i) => {
|
|
53
|
+
const on = selSet.has(id);
|
|
54
|
+
const name = it.titleText ?? (typeof it.title === "string" ? it.title : id);
|
|
55
|
+
const check = selectable ? (
|
|
56
|
+
<label className="ui-list__check">
|
|
57
|
+
<input type="checkbox" checked={on} aria-label={`Select ${name}`} onChange={(e) => toggle(i, e.target.checked, (e.nativeEvent as MouseEvent).shiftKey)} />
|
|
58
|
+
</label>
|
|
59
|
+
) : null;
|
|
60
|
+
return <ResourceItem key={id} {...it} linkAs={it.linkAs ?? linkAs} selected={on} check={check} />;
|
|
61
|
+
})}
|
|
62
|
+
</ResourceList>;
|
|
63
|
+
if (!selectable) return list;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="ui-resource" ref={rootRef}>
|
|
67
|
+
<BulkBar actions={bulkActions} chosen={chosen} all={all} some={some} onToggleAll={toggleAll} selectedLabel={selectedLabel} selectAllLabel={selectAllLabel}
|
|
68
|
+
idle="disable" boxDisabled={!ids.length} />
|
|
69
|
+
{list}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a click on one segment MEANS.
|
|
3
|
+
*
|
|
4
|
+
* A `.ts` beside the `.tsx` for the same reason `zoom-about.ts` sits beside `DesignCanvas.tsx`:
|
|
5
|
+
* `node:test` strips types but does NOT transform JSX, so anything importable from a component file
|
|
6
|
+
* is untestable here. A one-line decision left inline in a handler is a decision with no test.
|
|
7
|
+
*
|
|
8
|
+
* And it IS a decision: a click on the ALREADY-ACTIVE option clears when the caller offered
|
|
9
|
+
* `onClear`, and re-selects when it did not. Backwards, and you get a control that cannot be unset —
|
|
10
|
+
* which is exactly the behaviour the element inspector kept its own private copy of `Segmented` for.
|
|
11
|
+
*/
|
|
12
|
+
export function segmentedClick<T extends string>(
|
|
13
|
+
active: boolean,
|
|
14
|
+
value: T,
|
|
15
|
+
onChange: (v: T) => void,
|
|
16
|
+
onClear?: () => void,
|
|
17
|
+
): void {
|
|
18
|
+
if (active && onClear) { onClear(); return; }
|
|
19
|
+
onChange(value);
|
|
20
|
+
}
|