@gemboss/ui 0.34.3 → 0.35.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.
@@ -0,0 +1,159 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SettingsDialog — @gemboss/ui 0.35.0. Settings as ONE popup over the app, the admin's shape (apps/admin
5
+ * SettingsDialog, "Claude's and Shopify's settings"): sections in a rail on the left with a search box, the chosen
6
+ * section on the right, the close button in the corner; under 861px the rail becomes a strip above the section.
7
+ *
8
+ * Why in the package (Chris 12/10/2026, partner-radar: "settings thì bật popup theo chuẩn của gemboss ui"): the admin
9
+ * drew it with its own module CSS, GemWatcher copied that file (#400, and noted the package's Dialog could not do it
10
+ * because it always draws a title row), and partner-radar would have been the third copy.
11
+ *
12
+ * It is the SHELL only: no settings of its own, no data. The surface gives the sections and renders the active one as
13
+ * `children`. A section is a button (`onSelect`, the dialog keeps it on screen) or a link (`href` drawn with `linkAs`,
14
+ * for a surface whose sections are routes). Opening and closing go through the same hook as `Dialog` and `Sheet`
15
+ * (`useModalDialog`): Escape, a press on the backdrop, the inert page, focus back to the opener; the page behind holds
16
+ * still while it is open.
17
+ */
18
+ import "./settings-dialog.css";
19
+ import React, { useEffect, useId, useMemo, useState } from "react";
20
+ import { Icon } from "./icons";
21
+ import { useModalDialog } from "./dialog";
22
+
23
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
24
+
25
+ export interface SettingsSectionItem {
26
+ id: string;
27
+ title: string;
28
+ /** Under the section's title in the pane head. */
29
+ description?: string;
30
+ /** Rail heading the section sits under; sections of one group are given together, in order. */
31
+ group?: string;
32
+ /** A 16px icon (`<Icon name="…" />`). */
33
+ icon?: React.ReactNode;
34
+ /** More words the search box matches (title, description and group are matched already). */
35
+ keywords?: string;
36
+ /** A link instead of a button: the surface's own route for the section, drawn with `linkAs`. */
37
+ href?: string;
38
+ }
39
+
40
+ interface SettingsDialogBase {
41
+ open: boolean;
42
+ /** Every way it closes: Escape, ✕, a press on the backdrop, a foot link, and `open` turning false. Keep it idempotent. */
43
+ onClose: () => void;
44
+ activeId: string;
45
+ /** What draws `href` sections and foot links (Next's Link); a plain <a> without it. Gets `href`, `className`, `aria-current`, `onClick`, `children`. */
46
+ linkAs?: React.ElementType;
47
+ /** Links at the foot of the rail (pages that live outside settings). Following one closes the popup. */
48
+ footLinks?: Array<{ href: string; title: string; icon?: React.ReactNode }>;
49
+ /** The active section. */
50
+ children?: React.ReactNode;
51
+ /** The measure of the section's content: `form` 662px (one column of fields), `default` 900px, `full` the pane. A form set
52
+ * 1,500px wide at 1920 is not a wider form, it is an unreadable one (the admin's own note). */
53
+ measure?: "form" | "default" | "full";
54
+ searchLabel?: string;
55
+ closeLabel?: string;
56
+ railLabel?: string;
57
+ /** The rail's line when nothing matches. */
58
+ noMatch?: (query: string) => React.ReactNode;
59
+ /** Where focus goes on close when the element that opened it is gone (a menu item, a palette row): a selector. */
60
+ returnFocusTo?: string;
61
+ className?: string;
62
+ }
63
+
64
+ /** Button sections need `onSelect`; a surface whose sections are all links (`href`) leaves it out. A button with nothing to
65
+ * call would look chosen and do nothing (INERT), so the types refuse it. */
66
+ export type SettingsDialogProps = SettingsDialogBase & (
67
+ | { sections: SettingsSectionItem[]; onSelect: (id: string) => void }
68
+ | { sections: Array<SettingsSectionItem & { href: string }>; onSelect?: undefined }
69
+ );
70
+
71
+ const MEASURE_CLASS = { form: "ui-settings__measure ui-settings__measure--form", default: "ui-settings__measure", full: "ui-settings__measure ui-settings__measure--full" };
72
+
73
+ export function SettingsDialog({
74
+ open, onClose, sections, activeId, onSelect, linkAs: LinkAs = "a", footLinks = [], children, measure = "default",
75
+ searchLabel = "Search settings", closeLabel = "Close settings", railLabel = "Settings sections",
76
+ noMatch = (q) => <>No setting matches “{q}”.</>, returnFocusTo = "[data-settings-trigger]", className,
77
+ }: SettingsDialogProps) {
78
+ // The popup itself takes focus on open (as Sheet): its first control would be ✕ or, on a phone, the search box that pulls
79
+ // up the keyboard. Tab goes to the search box next; ✕ is last in the order, drawn in the corner.
80
+ const [query, setQuery] = useState("");
81
+ // Each opening starts from the whole rail: a query typed (or autofilled) last time would hide every section (review #4073).
82
+ // Cleared when it CLOSES, not only when `open` turns true: the close event is a queued task, and a reopen that lands before
83
+ // the surface's onClose leaves `open` true throughout (measured in CI).
84
+ const { ref, onClick, onPointerDown } = useModalDialog(open, () => { setQuery(""); onClose(); }, { focusPanel: true, returnFocusTo });
85
+ const titleId = useId();
86
+ useEffect(() => { if (open) setQuery(""); }, [open]);
87
+ const groups = useMemo(() => {
88
+ const q = query.trim().toLowerCase();
89
+ const out: Array<{ title: string; sections: SettingsSectionItem[] }> = [];
90
+ for (const s of sections as SettingsSectionItem[]) {
91
+ const g = s.group ?? "";
92
+ if (q && !`${s.title} ${s.description ?? ""} ${s.keywords ?? ""} ${g}`.toLowerCase().includes(q)) continue;
93
+ const last = out[out.length - 1];
94
+ if (last && last.title === g) last.sections.push(s);
95
+ else out.push({ title: g, sections: [s] });
96
+ }
97
+ return out;
98
+ }, [sections, query]);
99
+ // No silent fallback to the first section: the rail, the title and the popup's name would describe one section while the
100
+ // surface's children show another (review #4073).
101
+ const active = (sections as SettingsSectionItem[]).find((s) => s.id === activeId);
102
+ const close = () => ref.current?.close();
103
+ const item = (s: SettingsSectionItem) => {
104
+ const on = s.id === active?.id;
105
+ const inner = <>{s.icon}<span className="ui-settings__label">{s.title}</span></>;
106
+ const cls = cx("ui-settings__item", on && "ui-settings__item--active");
107
+ return s.href
108
+ ? <LinkAs key={s.id} href={s.href} className={cls} aria-current={on ? "page" : undefined}>{inner}</LinkAs>
109
+ : <button key={s.id} type="button" className={cls} aria-current={on ? "true" : undefined} onClick={() => onSelect?.(s.id)}>{inner}</button>;
110
+ };
111
+ return (
112
+ <dialog ref={ref} className={cx("ui-modal ui-modal--lg ui-settings", className)} aria-labelledby={titleId} tabIndex={-1} onClick={onClick} onPointerDown={onPointerDown}>
113
+ <div className="ui-settings__layout">
114
+ <nav className="ui-settings__rail" aria-label={railLabel}>
115
+ <div className="ui-settings__search">
116
+ {/* The kit's field made of parts: the label is the box (one 44px target under a thumb), the input draws nothing.
117
+ autoComplete off + its own name: Chrome filled the signed-in email into the admin's box (07/10/2026) and
118
+ the dialog opened on "No setting matches <email>". */}
119
+ <label className="gemboss-field gemboss-field--group">
120
+ <Icon name="search" size={16} />
121
+ <input className="gemboss-field__bare" type="search" name="settings-search" autoComplete="off" value={query}
122
+ onChange={(e) => setQuery(e.target.value)} placeholder={searchLabel} aria-label={searchLabel}
123
+ // A search field's own Escape only clears the words, and the popup needed a second press (the defect
124
+ // SearchDialog fixed in #3945): one Escape closes, as everywhere else in the popup.
125
+ onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); close(); } }} />
126
+ </label>
127
+ </div>
128
+ <div className="ui-settings__scroll">
129
+ {groups.length === 0 ? <p className="ui-settings__empty">{noMatch(query)}</p> : groups.map((g, i) => (
130
+ <div key={`${i}:${g.title}`} className="ui-settings__group">
131
+ {g.title ? <h3 className="ui-settings__group-title">{g.title}</h3> : null}
132
+ {g.sections.map(item)}
133
+ </div>
134
+ ))}
135
+ </div>
136
+ {footLinks.length ? (
137
+ <div className="ui-settings__foot">
138
+ {footLinks.map((l) => (
139
+ // A page outside settings: following it closes the popup, or it would stay over the new page (inert).
140
+ <LinkAs key={l.href} href={l.href} className="ui-settings__item" onClick={close}>{l.icon}<span className="ui-settings__label">{l.title}</span></LinkAs>
141
+ ))}
142
+ </div>
143
+ ) : null}
144
+ </nav>
145
+ <section className="ui-settings__pane" aria-labelledby={titleId}>
146
+ <div className="ui-settings__pane-head">
147
+ <h2 className="ui-settings__pane-title" id={titleId}>{active?.title}</h2>
148
+ {active?.description ? <p className="ui-settings__pane-sub">{active.description}</p> : null}
149
+ </div>
150
+ {/* Keyed by section: a new section opens at its top, not at the scroll position of the last one. */}
151
+ <div key={active?.id ?? ""} className="ui-settings__pane-body"><div className={MEASURE_CLASS[measure]}>{children}</div></div>
152
+ </section>
153
+ </div>
154
+ <button type="button" className="ui-modal__close ui-settings__close" aria-label={closeLabel} onClick={close}>
155
+ <Icon name="close" size={20} />
156
+ </button>
157
+ </dialog>
158
+ );
159
+ }
package/src/sheet.css ADDED
@@ -0,0 +1 @@
1
+ dialog.ui-modal.ui-sheet{margin:0 0 0 auto;inset-block:0;padding:0;box-sizing:border-box;width:min(560px,100%);max-width:100%;height:100dvh;max-height:100dvh;border-width:0 0 0 1px;border-radius:0}dialog.ui-modal.ui-sheet.ui-sheet--wide{width:min(760px,100%)}dialog.ui-modal.ui-sheet[open]{display:flex;flex-direction:column;animation:ui-sheet-in .2s cubic-bezier(.2,.8,.2,1)}dialog.ui-modal.ui-sheet>.ui-modal__head,dialog.ui-modal.ui-sheet>.ui-modal__foot{flex:none}dialog.ui-modal.ui-sheet>.ui-modal__body{flex:1 1 auto;min-height:0;overflow:auto;overscroll-behavior:contain}dialog.ui-modal.ui-sheet:focus{outline:none}dialog.ui-modal.ui-sheet::backdrop{background:#0000004d}@keyframes ui-sheet-in{0%{transform:translate(100%)}}@media (prefers-reduced-motion: reduce){dialog.ui-modal.ui-sheet[open]{animation:none}}@media (max-width: 47.9975em){dialog.ui-modal.ui-sheet,dialog.ui-modal.ui-sheet.ui-sheet--wide{width:100%;border-left-width:0}}
package/src/sheet.tsx ADDED
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Sheet — @gemboss/ui 0.35.0. The kit popup docked to the right edge, full height: a record opened over the list
5
+ * it came from (a person, an organisation, an app), read, then closed back to the same scroll position. Chris
6
+ * 12/10/2026 on partner-radar: "bấm vào people của từng người nên trượt ra side popup".
7
+ *
8
+ * Why not one of the others: `Dialog` is centred and covers the middle of the list; `EditorDrawer` (@gemboss/ui/editor)
9
+ * has the right shape but is the editor's own (no Escape, focus stays behind, brings editor.css); the assistant's side
10
+ * panel belongs to the assistant.
11
+ *
12
+ * It IS `Dialog` (dialog.tsx), same markup and behaviour, with `ui-sheet` on it: sheet.css moves it to the right edge,
13
+ * the full height, slides it in, and makes it the whole screen on a phone. Two differences in what it asks of Dialog:
14
+ * the head's subtitle and actions (the link to the record's full page) are the common case here, and on open the panel
15
+ * itself takes focus, so the Enter that opened the row does not, pressed again, follow that link.
16
+ */
17
+ import "./sheet.css";
18
+ import React from "react";
19
+ import { Dialog, type DialogProps } from "./dialog";
20
+
21
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
22
+
23
+ export type SheetProps = Omit<DialogProps, "focusPanel">;
24
+
25
+ /** `wide`: 760px instead of 560px, a record whose body holds a table. */
26
+ export function Sheet({ wide = false, className, ...rest }: SheetProps) {
27
+ return <Dialog {...rest} className={cx("ui-sheet", wide && "ui-sheet--wide", className)} focusPanel />;
28
+ }