@gemboss/ui 0.12.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 +395 -1
- package/README.md +321 -6
- package/catalog/editor.html +611 -0
- package/catalog/index.html +3114 -13
- package/gemboss-ui.css +3 -4682
- package/package.json +16 -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 -0
- package/src/collections.tsx +543 -0
- 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 +179 -0
- package/src/index.ts +19 -0
- package/src/kit-plain.css +1 -483
- package/src/kit-plain.js +1 -440
- 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
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
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import { segmentedClick } from "./segmented-click";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared tool-UI segmented control — token-driven (`.ui-seg`), cross-surface. Two forms:
|
|
6
|
+
* - <Segmented> text tabs (options + value)
|
|
7
|
+
* - <IconSegmented> icon squares (e.g. a grid/list view toggle)
|
|
8
|
+
* The selected segment uses the accent-soft state.
|
|
9
|
+
*
|
|
10
|
+
* In the package since 0.20.0 (was the admin's components/ui/Segmented.tsx): the editor kit's `Seg` (editor/controls)
|
|
11
|
+
* draws through it too, so the inspector's field switches and the Studio's mode switch are one component, not two with
|
|
12
|
+
* one look. Its own module and subpath (`@gemboss/ui/segmented`): a surface that draws a switch does not load the shell.
|
|
13
|
+
*
|
|
14
|
+
* THE ONLY ONE (06/10/2026). Every "pick one of these" switch in the admin draws through here: the Studio's
|
|
15
|
+
* Funnel · Canvas · Page, the funnel lens, the canvas's device, the inspector's field switches, the
|
|
16
|
+
* post-purchase step and device. Before, the same control had nine looks (a dark pill, a dark joined group,
|
|
17
|
+
* a grey track with a raised tab, …) and two of them sat side by side in one bar. `no-hand-rolled-switch.test.ts`
|
|
18
|
+
* (in the admin) fails on a new `role="group"` of `aria-pressed` buttons anywhere else.
|
|
19
|
+
*/
|
|
20
|
+
export type SegmentedOption<T extends string> = {
|
|
21
|
+
value: T;
|
|
22
|
+
label: ReactNode;
|
|
23
|
+
/** Tooltip; also the reason when the option is off ("Pick a step with a page first"). */
|
|
24
|
+
title?: string;
|
|
25
|
+
/** The accessible name when the label alone is not the full sentence (a count badge, an icon). */
|
|
26
|
+
ariaLabel?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function Segmented<T extends string>({
|
|
31
|
+
options, value, onChange, ariaLabel, disabled, onClear, fill = false, className, style,
|
|
32
|
+
}: {
|
|
33
|
+
options: SegmentedOption<T>[];
|
|
34
|
+
value: T | "";
|
|
35
|
+
onChange: (v: T) => void;
|
|
36
|
+
ariaLabel: string;
|
|
37
|
+
/** Greys the whole group while a write is in flight. */
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Present ⇒ clicking the ALREADY-ACTIVE option clears the setting instead of re-selecting it.
|
|
41
|
+
*
|
|
42
|
+
* A separate callback rather than widening `onChange` to `(v: T | "")`. Widening would break the
|
|
43
|
+
* four surfaces already passing a `(v: T) => void` handler — under `strictFunctionTypes` that is
|
|
44
|
+
* not assignable — and the alternative, calling `onChange("" as T)`, is a lie about the type in a
|
|
45
|
+
* component four other surfaces read. A clearing control is a different verb; it gets its own prop.
|
|
46
|
+
*/
|
|
47
|
+
onClear?: () => void;
|
|
48
|
+
/** Stretch to the row and share it equally: a field switch in the inspector's narrow column. */
|
|
49
|
+
fill?: boolean;
|
|
50
|
+
/** A hook class beside `ui-seg` (the editor kit's `gemboss-ei-seg`, which editor-plain.js finds). Never a look. */
|
|
51
|
+
className?: string;
|
|
52
|
+
/** On the group: the inspector narrows a two-option row with a max-width. */
|
|
53
|
+
style?: CSSProperties;
|
|
54
|
+
}) {
|
|
55
|
+
return (
|
|
56
|
+
<div className={`ui-seg${fill ? " ui-seg--fill" : ""}${className ? ` ${className}` : ""}`} style={style} role="group" aria-label={ariaLabel || undefined}>
|
|
57
|
+
{options.map((o) => {
|
|
58
|
+
const active = value === o.value;
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
key={o.value}
|
|
62
|
+
type="button"
|
|
63
|
+
// What a page without React reads back (editor-plain.js): the label can be anything.
|
|
64
|
+
data-value={o.value}
|
|
65
|
+
disabled={disabled || o.disabled}
|
|
66
|
+
title={o.title}
|
|
67
|
+
aria-label={o.ariaLabel}
|
|
68
|
+
aria-pressed={active}
|
|
69
|
+
className={`is-text${active ? " active" : ""}`}
|
|
70
|
+
onClick={() => segmentedClick(active, o.value, onChange, onClear)}
|
|
71
|
+
>
|
|
72
|
+
{o.label}
|
|
73
|
+
</button>
|
|
74
|
+
);
|
|
75
|
+
})}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function IconSegmented<T extends string>({
|
|
81
|
+
options, value, onChange, ariaLabel,
|
|
82
|
+
}: {
|
|
83
|
+
/** `ariaLabel` is also the tooltip: an icon says nothing to a mouse until it is named. */
|
|
84
|
+
options: { value: T; icon: ReactNode; ariaLabel: string }[];
|
|
85
|
+
value: T;
|
|
86
|
+
onChange: (v: T) => void;
|
|
87
|
+
ariaLabel: string;
|
|
88
|
+
}) {
|
|
89
|
+
return (
|
|
90
|
+
<div className="ui-seg" role="group" aria-label={ariaLabel}>
|
|
91
|
+
{options.map((o) => (
|
|
92
|
+
<button key={o.value} type="button" className={value === o.value ? "active" : ""} onClick={() => onChange(o.value)} aria-label={o.ariaLabel} title={o.ariaLabel} aria-pressed={value === o.value}>
|
|
93
|
+
{o.icon}
|
|
94
|
+
</button>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|