@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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Selection for the collections (0.16.0), and the bulk bar that acts on it: the rules IndexTable and SelectableResourceList share, in one place (README
3
+ * "Collections"): own or controlled ids; Shift+click takes the range from the last box clicked (the anchor is an id, so
4
+ * a re-sort cannot move it; an anchor that left the rows starts over); select-all; Escape clears while focus is in the
5
+ * root, unless an open ⋯ menu or dialog inside it takes that Escape first. The ids handed out are in the rows' order and
6
+ * only the ones shown (a filtered-away id does not count).
7
+ *
8
+ * No "use client" of its own: only the two client modules import it (a server graph never reaches its hooks).
9
+ */
10
+ import React, { useCallback, useEffect, useRef, useState } from "react";
11
+
12
+ export type BulkAction = { content: React.ReactNode; onAction: (ids: string[]) => void; tone?: "critical" };
13
+
14
+ /** Escape belongs to these first, when they are OPEN and shown (a closed `<dialog>` or a hidden menu in a row does not take it). */
15
+ export const ESCAPE_OWNERS = 'details[open], dialog[open], [role="dialog"], [role="menu"]';
16
+
17
+ /** Is this Escape for a menu or a dialog in `root` rather than for the selection? */
18
+ export function escapeTaken(root: Element, target: EventTarget | null): boolean {
19
+ const t = target as Element | null;
20
+ if (t && typeof t.closest === "function" && t.closest(ESCAPE_OWNERS)) return true;
21
+ return Array.from(root.querySelectorAll(ESCAPE_OWNERS)).some((e) => e.getClientRects().length > 0);
22
+ }
23
+
24
+ export function useSelection<R extends HTMLElement>(ids: string[], selectedProp: string[] | undefined, onSelectionChange: ((ids: string[]) => void) | undefined, enabled: boolean) {
25
+ const [own, setOwn] = useState<string[]>([]);
26
+ const selected = selectedProp ?? own;
27
+ const setSelected = useCallback((next: string[]) => { if (!selectedProp) setOwn(next); onSelectionChange?.(next); }, [selectedProp, onSelectionChange]);
28
+ const rootRef = useRef<R>(null);
29
+ const anchorRef = useRef<string | null>(null);
30
+ const selSet = new Set(selected);
31
+ const chosen = ids.filter((id) => selSet.has(id));
32
+ const n = chosen.length;
33
+ const all = n > 0 && n === ids.length, some = n > 0 && !all;
34
+ useEffect(() => {
35
+ const root = rootRef.current;
36
+ if (!root || !enabled) return;
37
+ const onKey = (e: KeyboardEvent) => {
38
+ if (e.key !== "Escape" || e.defaultPrevented || n === 0 || escapeTaken(root, e.target)) return;
39
+ setSelected([]);
40
+ };
41
+ root.addEventListener("keydown", onKey);
42
+ return () => root.removeEventListener("keydown", onKey);
43
+ }, [n, enabled, setSelected]);
44
+ const toggle = (i: number, on: boolean, shift: boolean) => {
45
+ const next = new Set(selected);
46
+ const from = anchorRef.current ? ids.indexOf(anchorRef.current) : -1;
47
+ if (shift && from > -1) {
48
+ for (let k = Math.min(from, i); k <= Math.max(from, i); k++) { if (on) next.add(ids[k]); else next.delete(ids[k]); }
49
+ } else if (on) next.add(ids[i]); else next.delete(ids[i]);
50
+ anchorRef.current = ids[i];
51
+ setSelected(ids.filter((x) => next.has(x)));
52
+ };
53
+ const toggleAll = (on: boolean) => { anchorRef.current = null; setSelected(on ? ids : []); };
54
+ return { rootRef, selSet, chosen, n, all, some, toggle, toggleAll };
55
+ }
56
+
57
+ /** The bulk bar: its own select-all box, "n selected", the actions (they get the chosen ids). `idle` says what it does
58
+ * with nothing selected: "hide" (a table: it covers the header only while rows are selected) or "disable" (a list: it
59
+ * is the list's header, there from the start, its actions off). */
60
+ export function BulkBar({ actions, chosen, all, some, onToggleAll, selectedLabel, selectAllLabel, idle, boxDisabled }: {
61
+ actions: BulkAction[]; chosen: string[]; all: boolean; some: boolean; onToggleAll: (on: boolean) => void;
62
+ selectedLabel: (n: number) => React.ReactNode; selectAllLabel: string; idle: "hide" | "disable"; boxDisabled?: boolean }) {
63
+ const box = useRef<HTMLInputElement>(null);
64
+ useEffect(() => { if (box.current) box.current.indeterminate = some; }, [some]);
65
+ const n = chosen.length;
66
+ return (
67
+ <div className="ui-bulkbar" hidden={idle === "hide" && n === 0}>
68
+ <label className="ui-table__checkbox"><input ref={box} type="checkbox" checked={all} aria-label={selectAllLabel} disabled={boxDisabled} onChange={(e) => onToggleAll(e.target.checked)} /></label>
69
+ <span className="ui-bulkbar__count" aria-live="polite">{selectedLabel(n)}</span>
70
+ <span className="ui-bulkbar__actions">
71
+ {actions.map((b, i) => (
72
+ <button key={i} type="button" className={b.tone === "critical" ? "ui-btn ui-btn--sm ui-btn--critical" : "ui-btn ui-btn--sm"}
73
+ disabled={idle === "disable" && n === 0} onClick={() => b.onAction(chosen)}>{b.content}</button>
74
+ ))}
75
+ </span>
76
+ </div>
77
+ );
78
+ }