@gemboss/ui 0.10.0 → 0.13.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 +113 -0
- package/README.md +155 -2
- package/catalog/index.html +2156 -15
- package/gemboss-ui.css +126 -66
- package/package.json +2 -1
- package/src/app-shell.css +13 -0
- package/src/collections.css +350 -0
- package/src/collections.tsx +414 -0
- package/src/editor.css +22 -19
- package/src/gemboss-tokens.css +1 -1
- package/src/index-table.tsx +216 -0
- package/src/index.ts +11 -0
- package/src/kit-plain.css +42 -9
- package/src/kit-plain.js +136 -1
- package/src/theme-dark.css +1 -0
- package/src/ui.css +67 -54
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* IndexTable (0.13.0): a list of entities people open, compare and act on in bulk. Polaris IndexTable's shape on the
|
|
4
|
+
* classes of `@gemboss/ui/collections.css` (README "Collections").
|
|
5
|
+
*
|
|
6
|
+
* - The WHOLE row opens the item (Chris 05/10/2026): a click anywhere that is not a control follows the row's title link
|
|
7
|
+
* (`a.ui-table__title`), which stays a real link, so Cmd-click, middle-click and a screen reader all work. A click on
|
|
8
|
+
* a checkbox, a button, a menu, a select or another link does what that control does and nothing else; so does a
|
|
9
|
+
* click that ends a text selection. A row with two or more in-place editing controls should not be linked (`href`
|
|
10
|
+
* off; link only its title by putting the link in `title` yourself).
|
|
11
|
+
* - Selection only with bulk actions: a checkbox column with nothing to do with the selection is inert (Artifacts and
|
|
12
|
+
* GemCRM shipped exactly that). The bulk bar takes the header's place, carries its own select-all box (the folded list
|
|
13
|
+
* has no header), and Escape clears the selection.
|
|
14
|
+
* - Below 768 of ITS container the table folds into a list (collections.css): the title line, then the other cells on
|
|
15
|
+
* one caption line (`label` prefixes a value; `condensed: "hide"` leaves a cell out), checkbox and actions kept.
|
|
16
|
+
*
|
|
17
|
+
* Every prop that describes the table is plain data (rows of rendered cells, `sortHref` strings), so a server
|
|
18
|
+
* component can render it; the callbacks (`onSort`, `onSelectionChange`, bulk `onAction`) need a client parent.
|
|
19
|
+
* Its own module, "use client": nothing the admin imports pulls it in (first-load budget).
|
|
20
|
+
*/
|
|
21
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
22
|
+
import { ariaSort, SortHeader, CollectionSkeleton, type CollectionColumn, type CollectionSort, type SortDirection } from "./collections";
|
|
23
|
+
|
|
24
|
+
const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");
|
|
25
|
+
|
|
26
|
+
export type IndexTableRow = {
|
|
27
|
+
id: string;
|
|
28
|
+
/** The row's main text. With `href` the table makes it the row's link; without, pass your own `<a className="ui-table__title">` or leave the row unlinked. */
|
|
29
|
+
title: React.ReactNode;
|
|
30
|
+
/** The whole title for a screen tip when the ellipsis cuts it. */
|
|
31
|
+
titleText?: string;
|
|
32
|
+
href?: string;
|
|
33
|
+
/** "_blank" for a row that opens somewhere else (the row follows the same link). */
|
|
34
|
+
target?: string;
|
|
35
|
+
/** One line under the title (`ui-sub`). */
|
|
36
|
+
sub?: React.ReactNode;
|
|
37
|
+
/** Avatar 24/32 or a `ui-thumb` (40), left of the title. One size for the whole table. */
|
|
38
|
+
media?: React.ReactNode;
|
|
39
|
+
/** The other columns, by column key. */
|
|
40
|
+
cells: Record<string, React.ReactNode>;
|
|
41
|
+
/** One button and/or a ⋯ menu, at the end of the row. Never more than one visible button. */
|
|
42
|
+
actions?: React.ReactNode;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type BulkAction = { content: React.ReactNode; onAction: (ids: string[]) => void; tone?: "critical" };
|
|
46
|
+
|
|
47
|
+
export type IndexTableProps = {
|
|
48
|
+
/** The first column is the primary one (title, sub, media); the rest map to `row.cells[key]`. */
|
|
49
|
+
columns: CollectionColumn[];
|
|
50
|
+
rows: IndexTableRow[];
|
|
51
|
+
/** Names the table for a screen reader. */
|
|
52
|
+
label: string;
|
|
53
|
+
sort?: CollectionSort | null;
|
|
54
|
+
onSort?: (key: string, direction: SortDirection) => void;
|
|
55
|
+
/** Turns the checkbox column on. Only together with `bulkActions`. */
|
|
56
|
+
bulkActions?: BulkAction[];
|
|
57
|
+
/** Controlled selection; leave both out and the table keeps it. */
|
|
58
|
+
selected?: string[];
|
|
59
|
+
onSelectionChange?: (ids: string[]) => void;
|
|
60
|
+
/** "12 selected": `(n) => …`. */
|
|
61
|
+
selectedLabel?: (n: number) => React.ReactNode;
|
|
62
|
+
selectAllLabel?: string;
|
|
63
|
+
/** The link element for titles (a Next `Link` from a client parent). Default `<a>`. */
|
|
64
|
+
linkAs?: React.ElementType;
|
|
65
|
+
/** Below 768 the header is gone: these chips sort instead. Default: one per sortable column. */
|
|
66
|
+
sortLabel?: React.ReactNode;
|
|
67
|
+
state?: "ready" | "loading" | "busy" | "empty" | "no-match" | "error";
|
|
68
|
+
/** For empty / no-match / error: a `CollectionState`. The header stays above it. */
|
|
69
|
+
stateSlot?: React.ReactNode;
|
|
70
|
+
loadingRows?: number;
|
|
71
|
+
/** Whether the loading skeleton has an actions column (default: any row has actions). */
|
|
72
|
+
actionsColumn?: boolean;
|
|
73
|
+
className?: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Anything in a row that is a control of its own: a click on it is not a click on the row. */
|
|
77
|
+
export const ROW_CONTROLS = 'a[href], button, input, select, textarea, label, summary, details, dialog, [role="dialog"], [role="menu"], [role="button"], [role="menuitem"], [role="checkbox"], [contenteditable="true"], [contenteditable=""], [data-row-ignore], .ui-table__check';
|
|
78
|
+
/** Escape belongs to these first (a ⋯ menu, a dialog in the row): it closes them, not the selection. */
|
|
79
|
+
export const ESCAPE_OWNERS = 'details[open], dialog, [role="dialog"], [role="menu"]';
|
|
80
|
+
|
|
81
|
+
/** The click on a row, minus controls and text selection; follows the row's title link. Shared with kit-plain.js in spirit (same rules). */
|
|
82
|
+
export function followRow(e: React.MouseEvent<HTMLElement>): void {
|
|
83
|
+
const row = e.currentTarget;
|
|
84
|
+
const t = e.target as Element;
|
|
85
|
+
if (e.defaultPrevented || e.button > 1) return;
|
|
86
|
+
// A portal (a popover rendered elsewhere) still bubbles React events through the row: not a click on the row.
|
|
87
|
+
if (!(t instanceof Node) || !row.contains(t)) return;
|
|
88
|
+
const hit = t.closest ? t.closest(ROW_CONTROLS) : null;
|
|
89
|
+
if (hit && row.contains(hit)) return;
|
|
90
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
91
|
+
if (sel && !sel.isCollapsed && sel.anchorNode && row.contains(sel.anchorNode)) return;
|
|
92
|
+
const a = row.querySelector<HTMLAnchorElement>("a.ui-table__title[href]");
|
|
93
|
+
if (!a) return;
|
|
94
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { window.open(a.href, "_blank", "noopener"); return; }
|
|
95
|
+
a.click();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function IndexTable({ columns, rows, label, sort, onSort, bulkActions, selected: selectedProp, onSelectionChange, selectedLabel = (n) => `${n} selected`,
|
|
99
|
+
selectAllLabel = "Select all", linkAs: Link = "a", sortLabel = "Sort", state = "ready", stateSlot, loadingRows = 5, actionsColumn, className }: IndexTableProps) {
|
|
100
|
+
const selectable = !!(bulkActions && bulkActions.length);
|
|
101
|
+
const [own, setOwn] = useState<string[]>([]);
|
|
102
|
+
const selected = selectedProp ?? own;
|
|
103
|
+
const setSelected = useCallback((ids: string[]) => { if (!selectedProp) setOwn(ids); onSelectionChange?.(ids); }, [selectedProp, onSelectionChange]);
|
|
104
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
105
|
+
const anchorRef = useRef<string | null>(null); // Shift+click range anchor: an id, so a re-sort cannot move it
|
|
106
|
+
const ids = rows.map((r) => r.id);
|
|
107
|
+
const idSet = new Set(ids), selSet = new Set(selected);
|
|
108
|
+
const n = selected.filter((id) => idSet.has(id)).length;
|
|
109
|
+
const hasActions = rows.some((r) => r.actions);
|
|
110
|
+
const all = n > 0 && n === ids.length, some = n > 0 && !all;
|
|
111
|
+
const headBox = useRef<HTMLInputElement>(null), barBox = useRef<HTMLInputElement>(null);
|
|
112
|
+
useEffect(() => { for (const b of [headBox.current, barBox.current]) if (b) b.indeterminate = some; }, [some]);
|
|
113
|
+
// Escape clears the selection while focus is in the table (a menu open inside it closes first: it stops the key).
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const root = rootRef.current;
|
|
116
|
+
if (!root || !selectable) return;
|
|
117
|
+
const onKey = (e: KeyboardEvent) => {
|
|
118
|
+
if (e.key !== "Escape" || e.defaultPrevented || n === 0) return;
|
|
119
|
+
// A ⋯ menu or a dialog open in the table takes this Escape (it closes), not the selection.
|
|
120
|
+
if ((e.target as Element).closest?.(ESCAPE_OWNERS) || root.querySelector(ESCAPE_OWNERS)) return;
|
|
121
|
+
setSelected([]);
|
|
122
|
+
};
|
|
123
|
+
root.addEventListener("keydown", onKey);
|
|
124
|
+
return () => root.removeEventListener("keydown", onKey);
|
|
125
|
+
}, [n, selectable, setSelected]);
|
|
126
|
+
|
|
127
|
+
if (state === "loading") return <CollectionSkeleton pattern="index" columns={columns} rows={loadingRows} label={label} check={selectable} actions={actionsColumn ?? hasActions} />;
|
|
128
|
+
const showRows = state === "ready" || state === "busy";
|
|
129
|
+
const [primary, ...rest] = columns;
|
|
130
|
+
const toggleAll = (on: boolean) => { anchorRef.current = null; setSelected(on ? ids : []); };
|
|
131
|
+
const toggle = (i: number, on: boolean, shift: boolean) => {
|
|
132
|
+
const id = ids[i];
|
|
133
|
+
const next = new Set(selected);
|
|
134
|
+
const from = anchorRef.current ? ids.indexOf(anchorRef.current) : -1; // -1 when the anchor left the rows (filter, page)
|
|
135
|
+
if (shift && from > -1) {
|
|
136
|
+
for (let k = Math.min(from, i); k <= Math.max(from, i); k++) on ? next.add(ids[k]) : next.delete(ids[k]);
|
|
137
|
+
} else if (on) next.add(id); else next.delete(id);
|
|
138
|
+
anchorRef.current = id;
|
|
139
|
+
setSelected(ids.filter((x) => next.has(x)));
|
|
140
|
+
};
|
|
141
|
+
const sortable = columns.filter((c) => c.sortHref || (c.sortable && onSort));
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className={cx("ui-index", className)} ref={rootRef} aria-busy={state === "busy" ? true : undefined}>
|
|
145
|
+
{sortable.length && showRows ? (
|
|
146
|
+
<div className="ui-index__sortbar" role="group" aria-label={typeof sortLabel === "string" ? sortLabel : "Sort"}>
|
|
147
|
+
<span aria-hidden="true">{sortLabel}</span>
|
|
148
|
+
{sortable.map((c) => {
|
|
149
|
+
const on = sort?.key === c.key;
|
|
150
|
+
const arrow = on ? (sort!.direction === "ascending" ? " ↑" : " ↓") : "";
|
|
151
|
+
return c.sortHref
|
|
152
|
+
? <a key={c.key} className="ui-chip" href={c.sortHref} aria-current={on ? "true" : undefined}>{c.header}{arrow}</a>
|
|
153
|
+
: <button key={c.key} type="button" className="ui-chip" aria-pressed={on} onClick={() => onSort!(c.key, on && sort!.direction === "ascending" ? "descending" : "ascending")}>{c.header}{arrow}</button>;
|
|
154
|
+
})}
|
|
155
|
+
</div>
|
|
156
|
+
) : null}
|
|
157
|
+
{selectable ? (
|
|
158
|
+
<div className="ui-bulkbar" hidden={n === 0}>
|
|
159
|
+
<label className="ui-table__checkbox"><input ref={barBox} type="checkbox" checked={all} aria-label={selectAllLabel} onChange={(e) => toggleAll(e.target.checked)} /></label>
|
|
160
|
+
<span className="ui-bulkbar__count" aria-live="polite">{selectedLabel(n)}</span>
|
|
161
|
+
<span className="ui-bulkbar__actions">
|
|
162
|
+
{bulkActions!.map((b, i) => (
|
|
163
|
+
<button key={i} type="button" className={cx("ui-btn ui-btn--sm", b.tone === "critical" && "ui-btn--critical")} onClick={() => b.onAction(ids.filter((x) => selSet.has(x)))}>{b.content}</button>
|
|
164
|
+
))}
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
<div className="ui-table-wrap">
|
|
169
|
+
<table className="ui-table ui-table--index">
|
|
170
|
+
<caption className="ui-sr-only">{label}</caption>
|
|
171
|
+
<thead>
|
|
172
|
+
<tr>
|
|
173
|
+
{selectable ? <th className="ui-table__check" scope="col"><label className="ui-table__checkbox"><input ref={headBox} type="checkbox" checked={all} aria-label={selectAllLabel} onChange={(e) => toggleAll(e.target.checked)} disabled={!showRows || !rows.length} /></label></th> : null}
|
|
174
|
+
<th className="ui-table__primary" scope="col" aria-sort={ariaSort(primary, sort)}><SortHeader col={primary} sort={sort} onSort={onSort} /></th>
|
|
175
|
+
{rest.map((c) => <th key={c.key} scope="col" className={c.numeric ? "is-num" : undefined} aria-sort={ariaSort(c, sort)}><SortHeader col={c} sort={sort} onSort={onSort} /></th>)}
|
|
176
|
+
{hasActions ? <th className="ui-table__actions" scope="col"><span className="ui-sr-only">Actions</span></th> : null}
|
|
177
|
+
</tr>
|
|
178
|
+
</thead>
|
|
179
|
+
{showRows ? (
|
|
180
|
+
<tbody>
|
|
181
|
+
{rows.map((r, i) => {
|
|
182
|
+
const isSel = selSet.has(r.id);
|
|
183
|
+
const title = r.href
|
|
184
|
+
? <Link className="ui-table__title" href={r.href} target={r.target} rel={r.target === "_blank" ? "noopener" : undefined} title={r.titleText}>{r.title}</Link>
|
|
185
|
+
: r.title;
|
|
186
|
+
return (
|
|
187
|
+
<tr key={r.id} className={cx(r.href && "is-linked", isSel && "is-selected")} aria-selected={selectable ? isSel : undefined} onClick={r.href ? followRow : undefined} onAuxClick={r.href ? followRow : undefined}>
|
|
188
|
+
{selectable ? (
|
|
189
|
+
<td className="ui-table__check" onClick={(e) => { if (e.target === e.currentTarget) (e.currentTarget.querySelector("input") as HTMLInputElement | null)?.click(); }}>
|
|
190
|
+
<label className="ui-table__checkbox">
|
|
191
|
+
<input type="checkbox" checked={isSel} aria-label={`Select ${r.titleText ?? (typeof r.title === "string" ? r.title : r.id)}`}
|
|
192
|
+
onChange={(e) => toggle(i, e.target.checked, (e.nativeEvent as MouseEvent).shiftKey)} />
|
|
193
|
+
</label>
|
|
194
|
+
</td>
|
|
195
|
+
) : null}
|
|
196
|
+
<td className="ui-table__primary" style={primary.truncate != null ? ({ ["--ui-trunc" as string]: typeof primary.truncate === "number" ? `${primary.truncate}px` : primary.truncate } as React.CSSProperties) : undefined}>
|
|
197
|
+
{r.media ? <div className="ui-table__primary-inner">{r.media}<div className="ui-table__primary-text">{title}{r.sub ? <span className="ui-sub">{r.sub}</span> : null}</div></div>
|
|
198
|
+
: <>{title}{r.sub ? <span className="ui-sub">{r.sub}</span> : null}</>}
|
|
199
|
+
</td>
|
|
200
|
+
{rest.map((c) => (
|
|
201
|
+
<td key={c.key} className={c.numeric ? "is-num" : undefined} data-label={c.label} data-condensed={c.condensed === "hide" ? "hide" : undefined}>
|
|
202
|
+
{c.truncate != null ? <span className="ui-trunc" style={{ ["--ui-trunc" as string]: typeof c.truncate === "number" ? `${c.truncate}px` : c.truncate } as React.CSSProperties}>{r.cells[c.key]}</span> : r.cells[c.key]}
|
|
203
|
+
</td>
|
|
204
|
+
))}
|
|
205
|
+
{hasActions ? <td className="ui-table__actions">{r.actions ? <span className="ui-row-actions">{r.actions}</span> : null}</td> : null}
|
|
206
|
+
</tr>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
209
|
+
</tbody>
|
|
210
|
+
) : null}
|
|
211
|
+
</table>
|
|
212
|
+
{!showRows ? stateSlot : null}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,17 @@ export { PageContainer, pageWidthClass } from "./page";
|
|
|
7
7
|
export type { PageWidth } from "./page";
|
|
8
8
|
// The loading boundary every route group under @gemboss/ui/next needs (0.10.0). Plain module, like ./page.
|
|
9
9
|
export { RouteCover, RouteHolding } from "./route-holding";
|
|
10
|
+
// Collections (0.13.0): tables, lists, grids, boards. Their own modules, like ./page: nothing the admin imports pulls them in
|
|
11
|
+
// (it draws tables with Polaris). ./collections is plain (a server component renders it); ./index-table is "use client".
|
|
12
|
+
export {
|
|
13
|
+
Board, CardGrid, CollectionSkeleton, CollectionState, CompactList, DataTable, MediaCard, ResourceItem, ResourceList, SortHeader, Truncate, ariaSort,
|
|
14
|
+
} from "./collections";
|
|
15
|
+
export type {
|
|
16
|
+
BoardColumn, CardGridMin, CollectionColumn, CollectionRow, CollectionSort, CollectionStateProps, CompactItem, DataTableProps, MediaCardProps,
|
|
17
|
+
ResourceItemProps, ResourceListProps, SkeletonPattern, SortDirection,
|
|
18
|
+
} from "./collections";
|
|
19
|
+
export { IndexTable, followRow, ROW_CONTROLS } from "./index-table";
|
|
20
|
+
export type { BulkAction, IndexTableProps, IndexTableRow } from "./index-table";
|
|
10
21
|
export { GemMark } from "./GemMark";
|
|
11
22
|
export { AuthError, AuthField, AuthFoot, AuthForm, AuthHelp, AuthLayout, PasswordField, AuthSubmit } from "./auth";
|
|
12
23
|
export { BrandLockup } from "./brand";
|
package/src/kit-plain.css
CHANGED
|
@@ -22,8 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
Key-value list: `<dl class="ui-kv"><dt>Owner</dt><dd>Lily</dd>…</dl>` — label column sized to
|
|
24
24
|
its longest label, value column takes the rest and wraps long keys and URLs. */
|
|
25
|
-
.ui-btn--icon { flex: none; width: var(--ui-
|
|
26
|
-
.ui-btn--icon.ui-btn--sm { width: 28px; }
|
|
25
|
+
.ui-btn--icon { flex: none; width: var(--ui-btn-h); padding: 0; }
|
|
27
26
|
.ui-btn--icon svg { width: 18px; height: 18px; }
|
|
28
27
|
/* Menu: the SURFACE is the shell's `gemboss-popover-surface` and the rows are the account menu's row (shell.css
|
|
29
28
|
declares `.gemboss-account-menu-item, .ui-menu__item` together), so a kit menu and the account menu on one page
|
|
@@ -89,7 +88,23 @@
|
|
|
89
88
|
.ui-link:focus-visible,
|
|
90
89
|
.ui-prose :where(a:not([class])):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
|
|
91
90
|
|
|
92
|
-
.ui-card--media { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
|
|
91
|
+
.ui-card--media { position: relative; padding: 0; overflow: hidden; display: flex; flex-direction: column; }
|
|
92
|
+
/* 0.13.0 (README "Collections", card grid): the title is the card's link and its overlay covers the card
|
|
93
|
+
(`a.ui-card__link`; the picture link is aria-hidden, out of the tab order); `.ui-card__menu` (a ⋯ ui-menu) sits in the
|
|
94
|
+
picture's corner above it, and the card stops clipping so the open menu is not cut off; `data-ratio="4/3"` for a theme
|
|
95
|
+
or a page. */
|
|
96
|
+
.ui-card__link { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--ui-text); font-size: var(--ui-fs-body); line-height: 20px; font-weight: 650; text-decoration: none; overflow-wrap: anywhere; }
|
|
97
|
+
.ui-card__link::after { content: ""; position: absolute; inset: 0; z-index: 0; }
|
|
98
|
+
.ui-card__link:focus-visible { outline: none; }
|
|
99
|
+
.ui-card--media:has(.ui-card__link) { cursor: pointer; }
|
|
100
|
+
.ui-card--media:has(.ui-card__link:hover) { border-color: var(--ui-text-muted); }
|
|
101
|
+
.ui-card--media:has(.ui-card__link:focus-visible) { outline: 2px solid var(--ui-focus); outline-offset: 1px; }
|
|
102
|
+
.ui-card--media :is(.ui-card__foot button, .ui-card__foot a, .ui-card__body a:not(.ui-card__link), .ui-card__body button, select, input, summary) { position: relative; z-index: 1; }
|
|
103
|
+
.ui-card__menu { position: absolute; top: 8px; right: 8px; z-index: 2; }
|
|
104
|
+
.ui-card--media:has(> .ui-card__menu) { overflow: visible; }
|
|
105
|
+
.ui-card--media:has(> .ui-card__menu) > .ui-card__media { border-radius: inherit; border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
|
|
106
|
+
.ui-card__meta { color: var(--ui-text-muted); font-size: var(--ui-fs-caption); line-height: 16px; }
|
|
107
|
+
.ui-card--media[data-ratio="4/3"] > .ui-card__media { aspect-ratio: 4 / 3; }
|
|
93
108
|
.ui-card__media {
|
|
94
109
|
display: block; width: 100%; aspect-ratio: 16 / 10; margin: 0; padding: 0; border: 0;
|
|
95
110
|
border-bottom: 1px solid var(--ui-border); background: var(--ui-surface-2); overflow: hidden; color: inherit;
|
|
@@ -114,8 +129,6 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
|
|
|
114
129
|
</div><div class="ui-modal__actions"><button class="ui-btn">Copy</button></div><button class="ui-modal__close">…` */
|
|
115
130
|
.ui-modal__subtitle { display: block; margin-top: 2px; font-size: var(--ui-fs-caption); line-height: 16px; font-weight: 450; color: var(--ui-text-muted); overflow-wrap: anywhere; }
|
|
116
131
|
.ui-modal__actions { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-2); }
|
|
117
|
-
/* An icon button in the popup's head or foot is square at the popup's 28 (ui.css gives those buttons 0/12 padding). */
|
|
118
|
-
:is(.ui-modal__head, .ui-modal__foot) .ui-btn--icon { width: 28px; padding: 0; }
|
|
119
132
|
/* A popup taller than the screen: the body scrolls, the head and foot stay, so the actions are always reachable
|
|
120
133
|
(320×568 with a long form put Huỷ/Xoá below the fold, unreachable under the locked page; review #3633). */
|
|
121
134
|
.ui-overlay > .ui-modal { display: flex; flex-direction: column; max-height: calc(100dvh - 40px); }
|
|
@@ -151,8 +164,8 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
|
|
|
151
164
|
dưới cùng (delta + so với gì). */
|
|
152
165
|
.ui-metrics--grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); gap: var(--ui-space-3); margin: 0; }
|
|
153
166
|
.ui-metric--lg { gap: 4px; padding: var(--ui-space-3) var(--ui-space-4); }
|
|
154
|
-
.ui-metric--lg .ui-metric__val { font-size:
|
|
155
|
-
.ui-metric--lg .ui-metric__lbl { order: -1; font-size: var(--
|
|
167
|
+
.ui-metric--lg .ui-metric__val { font-size: 24px; line-height: 32px; } /* Polaris headingXl, as the admin's analytics cards */
|
|
168
|
+
.ui-metric--lg .ui-metric__lbl { order: -1; font-size: var(--ui-fs-body); } /* bodyMd 13 (0.12: was 14) */
|
|
156
169
|
.ui-metric__foot { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; font-size: var(--gemboss-fs-caption); color: var(--ui-text-muted); }
|
|
157
170
|
@media (max-width: 520px) { .ui-metrics--grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
158
171
|
|
|
@@ -200,7 +213,7 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
|
|
|
200
213
|
sau bước đầu mang `__rate` = tỉ lệ đi tiếp từ bước NGAY TRƯỚC (bề mặt tính). Ngang ở khổ rộng, dọc ở ≤ 520px. */
|
|
201
214
|
.ui-funnel { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: var(--ui-space-2); margin: 0; padding: 0; list-style: none; }
|
|
202
215
|
.ui-funnel > li { position: relative; display: flex; flex-direction: column; gap: 2px; padding: var(--ui-space-3); border-radius: var(--ui-radius-md); background: var(--ui-surface-2); min-width: 0; }
|
|
203
|
-
.ui-funnel__val { font-size:
|
|
216
|
+
.ui-funnel__val { font-size: 20px; font-weight: 700; color: var(--ui-text); font-variant-numeric: tabular-nums; line-height: 24px; } /* Polaris headingLg */
|
|
204
217
|
.ui-funnel__lbl { font-size: var(--gemboss-fs-caption); color: var(--ui-text-muted); overflow-wrap: anywhere; }
|
|
205
218
|
.ui-funnel__rate { order: -1; font-size: var(--gemboss-fs-caption); font-weight: 600; color: var(--ui-series-1); font-variant-numeric: tabular-nums; }
|
|
206
219
|
@media (max-width: 520px) { .ui-funnel { grid-auto-flow: row; } }
|
|
@@ -215,7 +228,7 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
|
|
|
215
228
|
@media (max-width: 960px) { .ui-grid--2 { grid-template-columns: minmax(0, 1fr); } }
|
|
216
229
|
@media (max-width: 720px) { .ui-grid--3 { grid-template-columns: minmax(0, 1fr); } }
|
|
217
230
|
.ui-section-head { margin: var(--ui-space-4) 0 0; }
|
|
218
|
-
.ui-section-head > h2 { margin: 0; font-size:
|
|
231
|
+
.ui-section-head > h2 { margin: 0; font-size: 14px; line-height: 20px; font-weight: 650; color: var(--ui-text); } /* Polaris headingMd (0.12: was 16) */
|
|
219
232
|
.ui-section-head > p { margin: 2px 0 0; font-size: var(--gemboss-fs-caption); color: var(--ui-text-muted); }
|
|
220
233
|
|
|
221
234
|
/* Nhóm nút CHỮ không bao giờ bẻ chữ (03/10/2026, thanh lọc của gemnews ở 390px: "Mọi ngôn ngữ" thành hai dòng, "中文"
|
|
@@ -464,3 +477,23 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
|
|
|
464
477
|
@media (pointer: coarse), (max-width: 47.9975em) { .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 44px; min-height: 44px; } }
|
|
465
478
|
/* Phone: 11 targets of 44px wrapped into 3-4 rows at 320 (measured). Keep Previous · current · Next; the range wraps under. */
|
|
466
479
|
@media (max-width: 35.9975em) { .ui-pager > a:not([rel]):not([aria-current]):not([aria-disabled]), .ui-pager__gap { display: none; } }
|
|
480
|
+
|
|
481
|
+
/* 0.12.0: filter chip and card stack, both drawn by hand on every surface the 05/10/2026 density audit read.
|
|
482
|
+
Filter chip: `<div class="ui-chips"><button type="button" class="ui-chip" aria-pressed="true">Shopify Plus</button>
|
|
483
|
+
<a class="ui-chip" href="?country=us" aria-current="page">US</a></div>`. Polaris FilterPill's shape (radius 8, a 1px line,
|
|
484
|
+
dashed until applied) at the Button's density: 28px, 12px/550, so a filter row and a button row are one height.
|
|
485
|
+
Applied = `aria-pressed="true"` (in place) or `aria-current="page"` (a URL), solid line and the selected tint. */
|
|
486
|
+
.ui-chip {
|
|
487
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 4px; box-sizing: border-box;
|
|
488
|
+
height: var(--ui-btn-h, 28px); padding: 0 12px; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius-md);
|
|
489
|
+
background: var(--ui-surface); color: var(--ui-text); font: inherit; font-size: var(--ui-fs-caption); font-weight: 550;
|
|
490
|
+
line-height: 16px; white-space: nowrap; text-decoration: none; cursor: pointer;
|
|
491
|
+
}
|
|
492
|
+
.ui-chip:hover { border-style: solid; background: var(--ui-surface-2); }
|
|
493
|
+
.ui-chip:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: 1px; }
|
|
494
|
+
.ui-chip:is([aria-pressed="true"], [aria-current="page"], [aria-current="true"]) { border-style: solid; border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent-strong); }
|
|
495
|
+
@media (pointer: coarse), (max-width: 47.9975em) { .ui-chip { height: auto; min-height: 44px; min-width: 44px; } }
|
|
496
|
+
/* Card stack: `<div class="ui-stack"><div class="ui-card">…</div><div class="ui-card">…</div></div>`. 16px between blocks,
|
|
497
|
+
Polaris BlockStack gap 400, the admin's spacing between cards on a page. `--tight` is 8 (inside a card). */
|
|
498
|
+
.ui-stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--ui-space-4); }
|
|
499
|
+
.ui-stack--tight { gap: var(--ui-space-2); }
|
package/src/kit-plain.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
initPasswordFields(); // .gb-auth-pw-toggle reveal; resetPasswordFields() on sign-out
|
|
18
18
|
initSearch(); // TopbarSearch: ⌘K / Ctrl+K and "/" focus the box, Escape leaves it
|
|
19
19
|
// initNav() runs by itself (0.10.0): a followed rail link shows a spinner, a rested-on link is prefetched
|
|
20
|
+
// initCollections() runs by itself (0.13.0): rows with data-href open on a click, .ui-index selection + bulk bar,
|
|
21
|
+
// client sort; gembossUI.selection(root) → the selected ids (README "Collections")
|
|
20
22
|
*/
|
|
21
23
|
(function (g) {
|
|
22
24
|
"use strict";
|
|
@@ -434,7 +436,140 @@
|
|
|
434
436
|
}
|
|
435
437
|
initNav();
|
|
436
438
|
|
|
439
|
+
/* Collections (0.13.0), for the markup in README "Collections" (collections.css). Runs by itself when the script loads,
|
|
440
|
+
delegated on the document, so rows rendered later are covered; `initCollections()` is idempotent.
|
|
441
|
+
- Row links: a row with `data-href` (an index-table `tr`, or any element) opens on a click anywhere that is not a
|
|
442
|
+
control of its own (ROW_CONTROLS: a link, button, checkbox, select, menu, `data-row-ignore`) and does not end a
|
|
443
|
+
text selection. It follows the row's own link to that href (so a client router, `target` and the nav spinner all
|
|
444
|
+
apply); Cmd / Ctrl / Shift / middle click opens a new tab. No such link: location.assign. A React IndexTable
|
|
445
|
+
handles its own rows (`.is-linked`, no data-href), so the two never both fire.
|
|
446
|
+
- Selection: in a `.ui-index`, `input[data-gb-select]` (value = the row's id) and `input[data-gb-select-all]` (the
|
|
447
|
+
header's and the bulk bar's). The row gets `is-selected` + aria-selected, select-all goes indeterminate, the
|
|
448
|
+
`.ui-bulkbar` shows with `[data-gb-count]` filled from its own template ("{n} selected"), Shift+click selects a
|
|
449
|
+
range, Escape clears. The page reads `gembossUI.selection(root)` (ids) or listens for `gb:selection`.
|
|
450
|
+
- Sort, only in a table with `data-gb-sort` (a React table owns its own buttons): `="client"` sorts its rows when a
|
|
451
|
+
`button.ui-table__sort` is pressed (by each cell's `data-sort-value`, else its text; numbers as numbers) and sets
|
|
452
|
+
aria-sort; `="event"` fires `gb:sort` ({ key: th.dataset.key, direction }) for the page. A server sort is a link
|
|
453
|
+
(`a.ui-table__sort`) and needs nothing. */
|
|
454
|
+
var ROW_CONTROLS = 'a[href], button, input, select, textarea, label, summary, details, dialog, [role="dialog"], [role="menu"], [role="button"], [role="menuitem"], [role="checkbox"], [contenteditable="true"], [contenteditable=""], [data-row-ignore], .ui-table__check';
|
|
455
|
+
var ESCAPE_OWNERS = 'details[open], dialog, [role="dialog"], [role="menu"]';
|
|
456
|
+
var collOn = false, lastBox = null;
|
|
457
|
+
function rowLink(row) {
|
|
458
|
+
var href = row.getAttribute("data-href"), links = row.querySelectorAll("a[href]");
|
|
459
|
+
for (var i = 0; i < links.length; i++) if (links[i].getAttribute("href") === href) return links[i];
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
function onRowClick(e) {
|
|
463
|
+
if (e.defaultPrevented || e.button > 1 || !e.target.closest) return;
|
|
464
|
+
var row = e.target.closest("[data-href]");
|
|
465
|
+
if (!row) return;
|
|
466
|
+
var hit = e.target.closest(ROW_CONTROLS);
|
|
467
|
+
if (hit && row.contains(hit)) return;
|
|
468
|
+
var sel = g.getSelection ? g.getSelection() : null;
|
|
469
|
+
if (sel && !sel.isCollapsed && sel.anchorNode && row.contains(sel.anchorNode)) return;
|
|
470
|
+
var a = rowLink(row), href = a ? a.href : new URL(row.getAttribute("data-href"), location.href).href;
|
|
471
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { g.open(href, "_blank", "noopener"); return; }
|
|
472
|
+
if (a) a.click(); else location.assign(href);
|
|
473
|
+
}
|
|
474
|
+
function selectionIn(root) {
|
|
475
|
+
var out = [];
|
|
476
|
+
root.querySelectorAll("input[data-gb-select]").forEach(function (b) { if (b.checked) out.push(b.value); });
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
function syncSelection(root) {
|
|
480
|
+
var boxes = root.querySelectorAll("input[data-gb-select]"), n = 0;
|
|
481
|
+
boxes.forEach(function (b) {
|
|
482
|
+
var row = b.closest("tr, .ui-list__item, [data-href]");
|
|
483
|
+
if (b.checked) n++;
|
|
484
|
+
if (row) { row.classList.toggle("is-selected", b.checked); row.setAttribute("aria-selected", b.checked ? "true" : "false"); }
|
|
485
|
+
});
|
|
486
|
+
root.querySelectorAll("input[data-gb-select-all]").forEach(function (b) { b.checked = n > 0 && n === boxes.length; b.indeterminate = n > 0 && n < boxes.length; });
|
|
487
|
+
var bar = root.querySelector(".ui-bulkbar");
|
|
488
|
+
if (bar) bar.hidden = n === 0;
|
|
489
|
+
var c = root.querySelector("[data-gb-count]");
|
|
490
|
+
if (c) c.textContent = (c.getAttribute("data-gb-count") || "{n} selected").replace("{n}", n);
|
|
491
|
+
root.dispatchEvent(new CustomEvent("gb:selection", { bubbles: true, detail: { ids: selectionIn(root), total: boxes.length } }));
|
|
492
|
+
}
|
|
493
|
+
function clearSelection(root) {
|
|
494
|
+
root.querySelectorAll("input[data-gb-select]").forEach(function (b) { b.checked = false; });
|
|
495
|
+
syncSelection(root);
|
|
496
|
+
}
|
|
497
|
+
function sortValue(td) {
|
|
498
|
+
if (!td) return "";
|
|
499
|
+
var v = td.getAttribute("data-sort-value");
|
|
500
|
+
return v != null ? v : td.textContent.trim();
|
|
501
|
+
}
|
|
502
|
+
function onSortClick(btn) {
|
|
503
|
+
var th = btn.closest("th"), table = btn.closest("table");
|
|
504
|
+
if (!th || !table) return;
|
|
505
|
+
var dir = th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending";
|
|
506
|
+
table.querySelectorAll("thead th[aria-sort]").forEach(function (x) { if (x !== th) x.setAttribute("aria-sort", "none"); });
|
|
507
|
+
th.setAttribute("aria-sort", dir);
|
|
508
|
+
if (table.getAttribute("data-gb-sort") !== "client") {
|
|
509
|
+
table.dispatchEvent(new CustomEvent("gb:sort", { bubbles: true, detail: { key: th.getAttribute("data-key"), direction: dir } }));
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
var col = Array.prototype.indexOf.call(th.parentElement.children, th), body = table.tBodies[0];
|
|
513
|
+
if (!body) return;
|
|
514
|
+
var rows = Array.prototype.slice.call(body.rows), sign = dir === "ascending" ? 1 : -1;
|
|
515
|
+
var num = function (s) { var x = String(s).replace(/[\s,%$€£¥]/g, ""); return x !== "" && !isNaN(+x) ? +x : null; };
|
|
516
|
+
rows.sort(function (r1, r2) {
|
|
517
|
+
var a = sortValue(r1.cells[col]), b = sortValue(r2.cells[col]), na = num(a), nb = num(b);
|
|
518
|
+
return sign * (na != null && nb != null ? na - nb : String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" }));
|
|
519
|
+
});
|
|
520
|
+
rows.forEach(function (r) { body.appendChild(r); });
|
|
521
|
+
}
|
|
522
|
+
function initCollections() {
|
|
523
|
+
if (collOn || typeof document === "undefined") return;
|
|
524
|
+
collOn = true;
|
|
525
|
+
document.addEventListener("click", function (e) {
|
|
526
|
+
var t = e.target;
|
|
527
|
+
if (!t || !t.closest) return;
|
|
528
|
+
// Only a table that asked for it (data-gb-sort): a React DataTable / IndexTable owns its own sort buttons.
|
|
529
|
+
var sb = t.closest("table[data-gb-sort] button.ui-table__sort");
|
|
530
|
+
if (sb) { onSortClick(sb); return; }
|
|
531
|
+
// A click in the checkbox cell around the box (40px cell, 24px box) selects, it does not open the row.
|
|
532
|
+
var cc = t.closest("td.ui-table__check, th.ui-table__check");
|
|
533
|
+
// Only the plain contract's boxes: a React IndexTable toggles its own cell (both would undo each other).
|
|
534
|
+
if (cc && t === cc) { var ib = cc.querySelector("input[data-gb-select], input[data-gb-select-all]"); if (ib) { ib.click(); return; } }
|
|
535
|
+
// Shift+click on a row box: remember it for the range (the change event carries no shiftKey).
|
|
536
|
+
var box = t.closest("input[data-gb-select]");
|
|
537
|
+
if (box) {
|
|
538
|
+
var root = box.closest(".ui-index");
|
|
539
|
+
if (root && e.shiftKey && lastBox && lastBox !== box && root.contains(lastBox)) {
|
|
540
|
+
var all = Array.prototype.slice.call(root.querySelectorAll("input[data-gb-select]"));
|
|
541
|
+
var i = all.indexOf(lastBox), j = all.indexOf(box), on = box.checked;
|
|
542
|
+
for (var k = Math.min(i, j); k <= Math.max(i, j); k++) all[k].checked = on;
|
|
543
|
+
syncSelection(root);
|
|
544
|
+
}
|
|
545
|
+
lastBox = box;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
onRowClick(e);
|
|
549
|
+
});
|
|
550
|
+
document.addEventListener("auxclick", function (e) { if (e.button === 1) onRowClick(e); });
|
|
551
|
+
document.addEventListener("change", function (e) {
|
|
552
|
+
var t = e.target;
|
|
553
|
+
if (!t || !t.matches) return;
|
|
554
|
+
var root = t.closest(".ui-index");
|
|
555
|
+
if (!root) return;
|
|
556
|
+
if (t.matches("input[data-gb-select-all]")) {
|
|
557
|
+
root.querySelectorAll("input[data-gb-select]").forEach(function (b) { b.checked = t.checked; });
|
|
558
|
+
syncSelection(root);
|
|
559
|
+
} else if (t.matches("input[data-gb-select]")) syncSelection(root);
|
|
560
|
+
});
|
|
561
|
+
document.addEventListener("keydown", function (e) {
|
|
562
|
+
if (e.key !== "Escape" || e.defaultPrevented || MODALS.length) return;
|
|
563
|
+
var root = document.activeElement && document.activeElement.closest ? document.activeElement.closest(".ui-index") : null;
|
|
564
|
+
// An open ⋯ menu or a dialog in the table takes this Escape (initMenus closes it), not the selection.
|
|
565
|
+
if (root && (document.activeElement.closest(ESCAPE_OWNERS) || root.querySelector(ESCAPE_OWNERS))) return;
|
|
566
|
+
if (root && root.querySelector("input[data-gb-select]:checked")) clearSelection(root);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
initCollections();
|
|
570
|
+
|
|
437
571
|
g.gembossUI = { openModal: openModal, toast: toast, confirm: confirm, initMenus: initMenus, closeMenus: closeMenus,
|
|
438
572
|
initSearch: initSearch, initShell: initShell, initPasswordFields: initPasswordFields, resetPasswordFields: resetPasswordFields,
|
|
439
|
-
initNav: initNav, openModals: function () { return MODALS.length; }
|
|
573
|
+
initNav: initNav, openModals: function () { return MODALS.length; },
|
|
574
|
+
initCollections: initCollections, selection: selectionIn, clearSelection: clearSelection, syncSelection: syncSelection };
|
|
440
575
|
})(globalThis);
|
package/src/theme-dark.css
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
--gemboss-danger-border: #5c2a24;
|
|
57
57
|
--ui-accent-fg: #0d0d12;
|
|
58
58
|
--ui-warning-fg: #e8c063;
|
|
59
|
+
--ui-card-shadow: none; /* a dark drop under a dark card is noise */
|
|
59
60
|
--gb-brand-ink: #f2f3f5;
|
|
60
61
|
}
|
|
61
62
|
/* Avatar initials in dark: the tone's light end, so the text clears 4.5:1 on its tinted disc. */
|