@gemboss/ui 0.2.0 → 0.3.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/README.md +75 -5
- package/catalog/index.html +140 -14
- package/gemboss-ui.css +784 -11
- package/package.json +6 -1
- package/src/assistant/dock.ts +83 -5
- package/src/assistant/host.tsx +113 -17
- package/src/assistant/index.ts +1 -1
- package/src/assistant/provider.tsx +75 -8
- package/src/assistant.css +90 -1
- package/src/chat-composer.css +110 -0
- package/src/chat.css +427 -0
- package/src/gemboss-field.css +4 -0
- package/src/icons.tsx +1 -0
- package/src/index.ts +1 -0
- package/src/kit-plain.css +110 -0
- package/src/kit-plain.js +179 -0
- package/src/menu.tsx +64 -0
- package/src/shell.css +8 -4
- package/src/ui.css +27 -4
package/src/kit-plain.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/* kit-plain.js: the BEHAVIOUR half of kit-plain.css, for surfaces that render HTML on the server and have no React
|
|
2
|
+
(GemFactory, gemcare, gempress, partner-radar). Each of them wrote its own popup, toast and menu-closing code; the
|
|
3
|
+
popup's Tab trap, stacking and "ask before closing" were each found missing by a review on some surface first.
|
|
4
|
+
GemFactory's copy (server/ui-kit.mjs, 02/10/2026) had all three, so it is the source.
|
|
5
|
+
|
|
6
|
+
A classic script, not a module: a server-rendered page can inline it (readFileSync(require.resolve(
|
|
7
|
+
"@gemboss/ui/kit-plain.js"))) or load it with <script src>, and use `globalThis.gembossUI` from its own inline code.
|
|
8
|
+
Strings it writes are the caller's: `title`, `body` and `foot` are HTML (escape your text), toast text is TEXT.
|
|
9
|
+
|
|
10
|
+
const { openModal, toast, initMenus } = gembossUI;
|
|
11
|
+
initMenus(); // details.ui-menu: outside click, item click, window blur and Escape close it
|
|
12
|
+
const m = openModal({ title: "Delete #12?", body: '<div class="ui-modal__body">…</div>', foot: "…" });
|
|
13
|
+
m.close(value); // onClose(value) runs once; Escape / backdrop / × close with null
|
|
14
|
+
toast("Saved"); toast("Could not save", { error: true });
|
|
15
|
+
*/
|
|
16
|
+
(function (g) {
|
|
17
|
+
"use strict";
|
|
18
|
+
if (g.gembossUI) return;
|
|
19
|
+
var CLOSE_SVG = '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M5 5l10 10M15 5L5 15" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/></svg>';
|
|
20
|
+
// `summary` is a control (a kit ui-menu's ⋯ button); anything with no box (inside a closed <details>, display:none,
|
|
21
|
+
// [hidden]) is not, or Shift+Tab would "focus" a hidden menu item and stick (review #3633).
|
|
22
|
+
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, iframe, [tabindex]:not([tabindex="-1"])';
|
|
23
|
+
var focusables = function (root) {
|
|
24
|
+
// checkVisibility knows a closed <details>' content (content-visibility: hidden still has boxes, so
|
|
25
|
+
// getClientRects alone counts its items); the box test is the fallback for browsers without it.
|
|
26
|
+
return Array.prototype.slice.call(root.querySelectorAll(FOCUSABLE)).filter(function (el) {
|
|
27
|
+
return el.checkVisibility ? el.checkVisibility({ visibilityProperty: true }) : el.getClientRects().length > 0;
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
var esc = function (s) { return String(s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); };
|
|
31
|
+
var MODALS = [];
|
|
32
|
+
var seq = 0;
|
|
33
|
+
var savedOverflow = null; // the page's own body overflow, put back when the LAST popup closes
|
|
34
|
+
|
|
35
|
+
/* Popup: .ui-overlay + .ui-modal, the same markup as the admin's Modal.tsx. Escape, a click on the backdrop and the ×
|
|
36
|
+
close it; Tab stays inside; popups stack and only the TOP one takes keys. `title` empty = no header bar (then
|
|
37
|
+
`label` names the dialog for screen readers). `beforeClose(value)` may return a Promise<boolean>: false keeps it
|
|
38
|
+
open (unsaved changes); it gets the close value, so a Save that calls m.close(data) can skip the question, and a
|
|
39
|
+
guard that THROWS keeps the popup open (closing would throw away what it guards). Focus returns to whatever had it
|
|
40
|
+
when the popup opened, or to the popup below when that element is gone or sits under it. */
|
|
41
|
+
function openModal(o) {
|
|
42
|
+
var title = o.title || "", label = o.label || "", closeLabel = o.closeLabel || "Close";
|
|
43
|
+
var opener = document.activeElement;
|
|
44
|
+
var overlay = document.createElement("div");
|
|
45
|
+
overlay.className = "ui-overlay" + (o.overlayClass ? " " + o.overlayClass : "");
|
|
46
|
+
overlay.setAttribute("role", "presentation");
|
|
47
|
+
var tid = "gb-modal-title-" + ++seq;
|
|
48
|
+
overlay.innerHTML = '<div class="ui-modal' + (o.className ? " " + o.className : "") + '" role="dialog" aria-modal="true" ' +
|
|
49
|
+
(title ? 'aria-labelledby="' + tid + '"' : 'aria-label="' + esc(label) + '"') + ' tabindex="-1">' +
|
|
50
|
+
(title ? '<div class="ui-modal__head"><div class="ui-modal__title" id="' + tid + '">' + title + '</div><button type="button" class="ui-modal__close" aria-label="' + esc(closeLabel) + '">' + CLOSE_SVG + "</button></div>" : "") +
|
|
51
|
+
(o.body || "") + (o.foot ? '<div class="ui-modal__foot">' + o.foot + "</div>" : "") + "</div>";
|
|
52
|
+
var panel = overlay.firstChild;
|
|
53
|
+
var asking = false;
|
|
54
|
+
var m = {
|
|
55
|
+
panel: panel,
|
|
56
|
+
overlay: overlay,
|
|
57
|
+
close: function (value, opts) {
|
|
58
|
+
var force = opts && opts.force;
|
|
59
|
+
if (MODALS.indexOf(m) < 0) return Promise.resolve();
|
|
60
|
+
if (!force && o.beforeClose) {
|
|
61
|
+
if (asking) return Promise.resolve();
|
|
62
|
+
asking = true;
|
|
63
|
+
return Promise.resolve().then(function () { return o.beforeClose(value); }).catch(function (err) { console.error(err); return false; }).then(function (go) {
|
|
64
|
+
asking = false;
|
|
65
|
+
// Closed by force while asking (a navigation): closing again would splice the NEWEST popup instead.
|
|
66
|
+
if (go && MODALS.indexOf(m) >= 0) finish(value);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
finish(value);
|
|
70
|
+
return Promise.resolve();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
function finish(value) {
|
|
74
|
+
MODALS.splice(MODALS.indexOf(m), 1);
|
|
75
|
+
removeEventListener("keydown", onKey);
|
|
76
|
+
overlay.remove();
|
|
77
|
+
var top = MODALS[MODALS.length - 1];
|
|
78
|
+
if (!top) { document.body.style.overflow = savedOverflow || ""; savedOverflow = null; }
|
|
79
|
+
if (opener && opener.isConnected && opener.focus && (!top || top.panel.contains(opener))) opener.focus();
|
|
80
|
+
else if (top) top.panel.focus();
|
|
81
|
+
if (o.onClose) o.onClose(value);
|
|
82
|
+
}
|
|
83
|
+
function onKey(e) {
|
|
84
|
+
if (MODALS[MODALS.length - 1] !== m) return;
|
|
85
|
+
// Escape while an IME is composing (zh, ja, vi telex) cancels the composition, not the dialog.
|
|
86
|
+
if (e.key === "Escape") { if (e.isComposing || e.keyCode === 229) return; e.preventDefault(); m.close(null); return; }
|
|
87
|
+
if (e.key !== "Tab") return;
|
|
88
|
+
var f = focusables(panel);
|
|
89
|
+
if (!f.length) return;
|
|
90
|
+
var first = f[0], last = f[f.length - 1];
|
|
91
|
+
if (!panel.contains(document.activeElement)) { e.preventDefault(); first.focus(); }
|
|
92
|
+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
93
|
+
else if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
94
|
+
}
|
|
95
|
+
// Close on the backdrop only when the press STARTED there: selecting text in a field and releasing past the panel
|
|
96
|
+
// edge fires click on the overlay (the common ancestor) and would throw the form away.
|
|
97
|
+
var downOnBackdrop = false;
|
|
98
|
+
overlay.addEventListener("mousedown", function (e) { downOnBackdrop = e.target === overlay; });
|
|
99
|
+
overlay.addEventListener("click", function (e) { if (e.target === overlay && downOnBackdrop) m.close(null); downOnBackdrop = false; });
|
|
100
|
+
var x = panel.querySelector(".ui-modal__head .ui-modal__close");
|
|
101
|
+
if (x) x.onclick = function () { m.close(null); };
|
|
102
|
+
addEventListener("keydown", onKey);
|
|
103
|
+
if (!MODALS.length) savedOverflow = document.body.style.overflow;
|
|
104
|
+
MODALS.push(m);
|
|
105
|
+
document.body.append(overlay);
|
|
106
|
+
document.body.style.overflow = "hidden";
|
|
107
|
+
setTimeout(function () { (panel.querySelector("[autofocus]") || panel).focus(); }, 0);
|
|
108
|
+
return m;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Toast: the admin's pill (.gemboss-toast), bottom centre, red when `error`. Text, not HTML. The viewport is a polite
|
|
112
|
+
live region; an ERROR toast is role="alert" so it is announced at once. `duration: 0` = stays until its × is
|
|
113
|
+
pressed (`dismissLabel` names it). When the region is created by this very toast, the toast goes in one frame
|
|
114
|
+
later: a live region that appears together with its first message is often not announced at all. */
|
|
115
|
+
function toast(text, opts) {
|
|
116
|
+
opts = opts || {};
|
|
117
|
+
var vp = document.querySelector(".gemboss-toast-viewport"), fresh = !vp;
|
|
118
|
+
if (!vp) {
|
|
119
|
+
vp = document.createElement("div");
|
|
120
|
+
vp.className = "gemboss-toast-viewport";
|
|
121
|
+
vp.setAttribute("role", "status");
|
|
122
|
+
vp.setAttribute("aria-live", "polite");
|
|
123
|
+
document.body.append(vp);
|
|
124
|
+
}
|
|
125
|
+
var t = document.createElement("div");
|
|
126
|
+
t.className = "gemboss-toast" + (opts.error ? " is-error" : "");
|
|
127
|
+
if (opts.error) t.setAttribute("role", "alert");
|
|
128
|
+
var span = document.createElement("span");
|
|
129
|
+
span.className = "gemboss-toast-text";
|
|
130
|
+
span.textContent = String(text);
|
|
131
|
+
t.append(span);
|
|
132
|
+
var sticky = opts.duration === 0;
|
|
133
|
+
if (sticky) {
|
|
134
|
+
var x = document.createElement("button");
|
|
135
|
+
x.type = "button";
|
|
136
|
+
x.className = "gemboss-toast-dismiss";
|
|
137
|
+
x.setAttribute("aria-label", opts.dismissLabel || "Dismiss");
|
|
138
|
+
x.innerHTML = CLOSE_SVG;
|
|
139
|
+
x.onclick = function () { t.remove(); };
|
|
140
|
+
t.append(x);
|
|
141
|
+
}
|
|
142
|
+
// Toasts sent while the new region is still waiting queue behind the first, so they keep their order.
|
|
143
|
+
if (fresh) vp.dataset.readyAt = String(Date.now() + 50);
|
|
144
|
+
var wait = Math.max(0, Number(vp.dataset.readyAt || 0) - Date.now());
|
|
145
|
+
var put = function () { vp.append(t); };
|
|
146
|
+
if (wait) setTimeout(put, wait); else put();
|
|
147
|
+
if (!sticky) setTimeout(function () { t.remove(); }, wait + (opts.duration || (opts.error ? 6000 : 3500)));
|
|
148
|
+
return t;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Menus (details.ui-menu, kit-plain.css) open with no script; closing is the page's job: an outside click, a click on
|
|
152
|
+
an item, the window losing focus (a click inside an iframe never reaches this document), and Escape. Escape closes
|
|
153
|
+
the MENU first and stops there, so a menu inside a popup does not close the popup with it. Idempotent. */
|
|
154
|
+
var menusOn = false;
|
|
155
|
+
function closeMenus(except) {
|
|
156
|
+
document.querySelectorAll("details.ui-menu[open]").forEach(function (d) { if (d !== except) d.open = false; });
|
|
157
|
+
}
|
|
158
|
+
function initMenus() {
|
|
159
|
+
if (menusOn) return;
|
|
160
|
+
menusOn = true;
|
|
161
|
+
document.addEventListener("click", function (e) {
|
|
162
|
+
var d = e.target.closest && e.target.closest("details.ui-menu");
|
|
163
|
+
closeMenus(d);
|
|
164
|
+
if (d && e.target.closest(".ui-menu__item")) d.open = false;
|
|
165
|
+
});
|
|
166
|
+
addEventListener("blur", function () { closeMenus(); });
|
|
167
|
+
addEventListener("keydown", function (e) {
|
|
168
|
+
var d = e.key === "Escape" && !e.isComposing && document.querySelector("details.ui-menu[open]");
|
|
169
|
+
if (!d) return;
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
e.stopImmediatePropagation();
|
|
172
|
+
d.open = false;
|
|
173
|
+
var s = d.querySelector("summary");
|
|
174
|
+
if (s) s.focus();
|
|
175
|
+
}, true);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
g.gembossUI = { openModal: openModal, toast: toast, initMenus: initMenus, closeMenus: closeMenus, openModals: function () { return MODALS.length; } };
|
|
179
|
+
})(globalThis);
|
package/src/menu.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// menu.tsx: the kit's action menu ("⋯") — a `<details>` disclosure, so it opens with no JavaScript and a
|
|
4
|
+
// server-rendered surface (GemFactory, gemcare) can print it as plain markup. The surface is the shell's
|
|
5
|
+
// `gemboss-popover-surface` and the rows share the account menu's row (shell.css), so every menu in the
|
|
6
|
+
// fleet looks the same (§9.3). Where React hydrates, the menu closes itself like AccountMenu does: outside
|
|
7
|
+
// click, Escape (focus back on the trigger), and a button row after its handler (review #3591: a React page
|
|
8
|
+
// had no clean way to close it). The catalog renders its example from these components.
|
|
9
|
+
import React, { useEffect, useRef } from "react";
|
|
10
|
+
import { Icon } from "./icons";
|
|
11
|
+
|
|
12
|
+
/** The trigger is an icon button (`label` is its accessible name). `end` aligns the list to the right edge. */
|
|
13
|
+
export function Menu({ label, icon, end, children, className }: {
|
|
14
|
+
label: string;
|
|
15
|
+
icon?: React.ReactNode;
|
|
16
|
+
end?: boolean;
|
|
17
|
+
children?: React.ReactNode;
|
|
18
|
+
className?: string;
|
|
19
|
+
}) {
|
|
20
|
+
const ref = useRef<HTMLDetailsElement>(null);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const el = ref.current;
|
|
23
|
+
if (!el) return undefined;
|
|
24
|
+
const onDoc = (e: MouseEvent) => { if (el.open && !el.contains(e.target as Node)) el.open = false; };
|
|
25
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && el.open) { el.open = false; el.querySelector("summary")?.focus(); } };
|
|
26
|
+
document.addEventListener("click", onDoc);
|
|
27
|
+
document.addEventListener("keydown", onKey);
|
|
28
|
+
return () => { document.removeEventListener("click", onDoc); document.removeEventListener("keydown", onKey); };
|
|
29
|
+
}, []);
|
|
30
|
+
return (
|
|
31
|
+
<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>
|
|
33
|
+
<div className="ui-menu__list gemboss-popover-surface">{children}</div>
|
|
34
|
+
</details>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One row: a link (`href`) or a button. `hint` is the quiet text on the right; `danger` for destructive rows.
|
|
39
|
+
* A disabled link is not a link: no href, aria-disabled, out of the tab order (an `<a disabled>` still follows
|
|
40
|
+
* on Enter). A caller's `className` is added to the row's class, not put in its place. */
|
|
41
|
+
export function MenuItem({ href, danger, hint, children, className, disabled, onClick, ...rest }: {
|
|
42
|
+
href?: string;
|
|
43
|
+
danger?: boolean;
|
|
44
|
+
hint?: React.ReactNode;
|
|
45
|
+
children?: React.ReactNode;
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
onClick?: React.MouseEventHandler<HTMLElement>;
|
|
48
|
+
} & Omit<React.HTMLAttributes<HTMLElement>, "onClick"> & Pick<React.AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel">) {
|
|
49
|
+
const cls = ["ui-menu__item", danger ? "ui-menu__item--danger" : "", className ?? ""].filter(Boolean).join(" ");
|
|
50
|
+
const body = <>{children}{hint ? <span className="ui-menu__hint">{hint}</span> : null}</>;
|
|
51
|
+
if (href) {
|
|
52
|
+
if (disabled) return <a className={cls} aria-disabled="true" tabIndex={-1} {...rest}>{body}</a>;
|
|
53
|
+
return <a className={cls} href={href} onClick={onClick} {...rest}>{body}</a>;
|
|
54
|
+
}
|
|
55
|
+
const close: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
|
56
|
+
onClick?.(e);
|
|
57
|
+
e.currentTarget.closest("details")?.removeAttribute("open");
|
|
58
|
+
};
|
|
59
|
+
return <button type="button" className={cls} disabled={disabled} onClick={close} {...rest}>{body}</button>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function MenuSeparator() {
|
|
63
|
+
return <hr className="ui-menu__sep" />;
|
|
64
|
+
}
|
package/src/shell.css
CHANGED
|
@@ -100,20 +100,24 @@
|
|
|
100
100
|
/* Account dropdown menu items (avatar chip) — CSS-driven hover/focus, replaces inline JS handlers. */
|
|
101
101
|
/* min-height 44 (28/09/2026): 12px padding around a 13px line measured 42.84px — under the 44px control
|
|
102
102
|
floor (CLAUDE.md §9.1), in the admin's own menu and in every surface wearing it through @gemboss/ui. */
|
|
103
|
-
.gemboss-account-menu-item
|
|
103
|
+
.gemboss-account-menu-item,
|
|
104
|
+
.ui-menu__item {
|
|
104
105
|
display: block; width: 100%; min-height: 44px; padding: 12px 16px; border: 0; background: transparent;
|
|
105
106
|
font: inherit; font-size: 13px; color: var(--gemboss-text); text-align: left; text-decoration: none;
|
|
106
107
|
cursor: pointer; transition: background var(--gemboss-transition-fast) ease, color var(--gemboss-transition-fast) ease;
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
.gemboss-account-menu-item:hover
|
|
110
|
+
.gemboss-account-menu-item:hover,
|
|
111
|
+
.ui-menu__item:hover { background: var(--gemboss-hover); }
|
|
110
112
|
|
|
111
|
-
.gemboss-account-menu-item:focus-visible
|
|
113
|
+
.gemboss-account-menu-item:focus-visible,
|
|
114
|
+
.ui-menu__item:focus-visible { outline: 2px solid var(--gemboss-primary); outline-offset: -2px; background: var(--gemboss-hover); }
|
|
112
115
|
|
|
113
116
|
/* `--gemboss-danger` var(--gemboss-danger) is 4.25:1 on `--gemboss-hover` #eceef2, and this row takes that hover
|
|
114
117
|
background two lines above — so the one moment the item is under the cursor is the moment its label
|
|
115
118
|
drops below AA. `--gemboss-danger-strong` #b42318 is 5.66:1 there and 7.30:1 on white. */
|
|
116
|
-
.gemboss-account-menu-item.danger
|
|
119
|
+
.gemboss-account-menu-item.danger,
|
|
120
|
+
.ui-menu__item--danger { color: var(--gemboss-danger-strong); }
|
|
117
121
|
|
|
118
122
|
.gemboss-account-chip {
|
|
119
123
|
width: 30px;
|
package/src/ui.css
CHANGED
|
@@ -154,7 +154,7 @@
|
|
|
154
154
|
background-color: var(--p-color-input-bg-surface-hover, var(--gemboss-field-bg-hover));
|
|
155
155
|
}
|
|
156
156
|
.ui-select:focus { outline: none; }
|
|
157
|
-
.ui-select:focus-visible {
|
|
157
|
+
.ui-select:focus-visible:not(:disabled) {
|
|
158
158
|
outline: 2px solid var(--p-color-border-focus, #005bd3); outline-offset: 1px;
|
|
159
159
|
border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active));
|
|
160
160
|
}
|
|
@@ -300,12 +300,35 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
|
|
|
300
300
|
consumer belongs in that consumer's chunk (see SettingsDialog.module.css). */
|
|
301
301
|
.ui-modal--lg { position: relative; }
|
|
302
302
|
|
|
303
|
+
/* The kit's multi-line field joins the ONE field family (01/10/2026, Chris: "form control đồng bộ"):
|
|
304
|
+
same input-border token, hover and focus ring as `gemboss-field` and `.ui-select`. Before, it drew a
|
|
305
|
+
lighter --ui-border box at 14px with no focus ring, so on GemFactory's compose form a textarea sat
|
|
306
|
+
beside selects of a darker border and text fields of a third look (measured on prod). */
|
|
303
307
|
.ui-textarea {
|
|
304
308
|
width: 100%; min-height: 96px; resize: vertical; box-sizing: border-box;
|
|
305
|
-
background: var(--
|
|
306
|
-
|
|
309
|
+
background: var(--p-color-input-bg-surface, var(--gemboss-field-bg));
|
|
310
|
+
border: 1px solid var(--p-color-input-border, var(--gemboss-field-line)); border-radius: var(--ui-radius-md);
|
|
311
|
+
padding: 12px; color: var(--p-color-text, var(--gemboss-field-ink)); font-size: var(--ui-fs-body); line-height: 1.5; font-family: inherit;
|
|
312
|
+
}
|
|
313
|
+
.ui-textarea::placeholder { color: var(--p-color-text-secondary, var(--gemboss-field-placeholder)); }
|
|
314
|
+
.ui-textarea:hover:not(:disabled) {
|
|
315
|
+
border-color: var(--p-color-input-border-hover, var(--gemboss-field-line-hover));
|
|
316
|
+
background-color: var(--p-color-input-bg-surface-hover, var(--gemboss-field-bg-hover));
|
|
317
|
+
}
|
|
318
|
+
.ui-textarea:focus { outline: none; }
|
|
319
|
+
.ui-textarea:focus-visible:not(:disabled) {
|
|
320
|
+
outline: 2px solid var(--p-color-border-focus, #005bd3); outline-offset: 1px;
|
|
321
|
+
border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active));
|
|
322
|
+
}
|
|
323
|
+
.ui-textarea:disabled { opacity: .5; cursor: not-allowed; }
|
|
324
|
+
/* On a PHONE-WIDTH screen every field of the family reads 16px (iOS zooms the page on tap below that)
|
|
325
|
+
and a select is a 44px target — by WIDTH, as `gemboss-field` does, because a desktop browser at 320px
|
|
326
|
+
still reports pointer: fine. Measured on GemFactory at 390px before this: select 13px, textarea 14px. */
|
|
327
|
+
@media (max-width: 47.9975em) {
|
|
328
|
+
.ui-select.ui-select, .ui-textarea { font-size: 16px; }
|
|
329
|
+
/* Height only for the full select: `--slim` is a density decision (table rows), as the coarse block keeps it. */
|
|
330
|
+
.ui-select:not(.ui-select--slim) { min-height: 44px; }
|
|
307
331
|
}
|
|
308
|
-
.ui-textarea:focus { border-color: var(--ui-accent); }
|
|
309
332
|
|
|
310
333
|
/* ── Segmented control ────────────────────────────────────────────────────────
|
|
311
334
|
Grouped toggle: icon squares (.ui-seg button) or text tabs (.is-text). The
|