gg-wf-scripts 1.0.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 ADDED
@@ -0,0 +1,265 @@
1
+ # gg-wf-scripts
2
+
3
+ A declarative attribute engine for Webflow sites backed by Supabase. Add `gg-*` attributes to your markup and the library handles data binding, URL-driven state, dialogs, auth gating, form visibility, and user actions.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ npm install gg-scripts
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ Create a site-specific entry file, register your queries and actions, then bundle it with esbuild.
14
+
15
+ ```js
16
+ import { init } from "gg-wf-scripts";
17
+
18
+ const app = init({
19
+ supabaseUrl: "https://your-project.supabase.co",
20
+ supabaseKey: "your-publishable-key",
21
+ });
22
+
23
+ app.addQuery("posts_list", async (sb) => {
24
+ const { data } = await sb
25
+ .from("posts")
26
+ .select("*")
27
+ .order("created_at", { ascending: false });
28
+ return data ?? [];
29
+ });
30
+
31
+ app.addAction("delete_post", async (sb, { id }) => {
32
+ const { error } = await sb.from("posts").delete().eq("id", id);
33
+ return error ? { ok: false, error } : { ok: true };
34
+ });
35
+
36
+ app.start();
37
+ ```
38
+
39
+ Bundle with esbuild:
40
+
41
+ ```
42
+ npx esbuild src/index.js --bundle --outfile=dist/site.js --format=iife --target=es2020 --platform=browser
43
+ ```
44
+
45
+ Load on your site:
46
+
47
+ ```html
48
+ <script src="https://your-cdn.com/site.js"></script>
49
+ ```
50
+
51
+ ## Attributes
52
+
53
+ ### Data binding
54
+
55
+ Display data from your queries in the DOM.
56
+
57
+ ```html
58
+ <!-- Single record: populates [gg-field] descendants -->
59
+ <div gg-data="post_by_id">
60
+ <h1 gg-field="title">Loading...</h1>
61
+ <p gg-field="body"></p>
62
+ </div>
63
+
64
+ <!-- Form pre-fill: sets value/checked on named inputs -->
65
+ <form gg-data-form="post_by_id">
66
+ <input name="title" />
67
+ <textarea name="body"></textarea>
68
+ </form>
69
+
70
+ <!-- List: clones the template for each record -->
71
+ <ul gg-data-list="posts_list">
72
+ <li gg-list-template>
73
+ <h3 gg-field="title"></h3>
74
+ <span gg-field="author.name"></span>
75
+ </li>
76
+ </ul>
77
+ ```
78
+
79
+ `gg-field` supports dot-paths for nested data (e.g. `author.name`).
80
+
81
+ #### Re-running on URL changes
82
+
83
+ Add `gg-data-on` to re-run a query when specific URL params change:
84
+
85
+ ```html
86
+ <div gg-data="post_by_id" gg-data-on="id">
87
+ <h1 gg-field="title"></h1>
88
+ </div>
89
+ ```
90
+
91
+ ### URL query params
92
+
93
+ Read and write URL query params declaratively.
94
+
95
+ ```html
96
+ <!-- On click, set ?modal=view&id=42 -->
97
+ <button gg-query-set="modal:view,id:42">Open</button>
98
+
99
+ <!-- On click, remove ?modal and ?id -->
100
+ <button gg-query-remove="modal,id">Close</button>
101
+ ```
102
+
103
+ ### Content switcher
104
+
105
+ Show/hide children based on a state value.
106
+
107
+ ```html
108
+ <!-- State driven by URL param ?view=... -->
109
+ <div gg-switch-query="view">
110
+ <section gg-case="">Pick a view</section>
111
+ <section gg-case="list">List view</section>
112
+ <section gg-case="grid">Grid view</section>
113
+ </div>
114
+
115
+ <!-- State driven by a field on the nearest gg-data record -->
116
+ <span gg-switch-field="status">
117
+ <span gg-case="published">Published</span>
118
+ <span gg-case="draft">Draft</span>
119
+ <span gg-case="">Unknown</span>
120
+ </span>
121
+ ```
122
+
123
+ `gg-case=""` acts as the default/empty state.
124
+
125
+ ### Dialog
126
+
127
+ A single `<dialog>` element is managed automatically via the `modal` URL param.
128
+
129
+ ```html
130
+ <div gg-query-set="modal:confirm,id:42">Delete</div>
131
+
132
+ <dialog>
133
+ <p>Are you sure?</p>
134
+ <button gg-query-remove="modal,id">Cancel</button>
135
+ </dialog>
136
+ ```
137
+
138
+ Setting `?modal=...` opens the dialog. Removing it (or pressing Escape, or clicking the backdrop) closes it. Back button navigation is handled automatically.
139
+
140
+ ### Auth and role gating
141
+
142
+ Show or hide elements based on Supabase auth state.
143
+
144
+ ```html
145
+ <a href="/login" gg-auth="false">Log in</a>
146
+ <a href="/account" gg-auth="true">My account</a>
147
+ <a href="/admin" gg-role="superuser">Admin panel</a>
148
+ ```
149
+
150
+ `gg-auth` is set on `<body>` as `"true"` or `"false"`. `gg-role` is set if you provide a `roleQuery` (see [Auth config](#auth-config)).
151
+
152
+ ### Form visibility
153
+
154
+ Conditionally show/hide elements based on form field values.
155
+
156
+ ```html
157
+ <form>
158
+ <label><input type="radio" name="reason" value="scheduling" /> Scheduling</label>
159
+ <label><input type="radio" name="reason" value="other" /> Other</label>
160
+
161
+ <div gg-visible-when="reason:other">
162
+ <input type="text" name="other_reason" placeholder="Please specify" />
163
+ </div>
164
+ </form>
165
+ ```
166
+
167
+ Hidden elements get `display: none`, `inert`, and `aria-hidden="true"`. Transitions are a 200ms opacity fade. Use `gg-form-scope` on a non-`<form>` ancestor when `closest("form")` can't reach the inputs (e.g. shadow DOM).
168
+
169
+ ### Actions
170
+
171
+ Run mutations on click. Actions receive the Supabase client and a data object.
172
+
173
+ ```html
174
+ <!-- Simple action, no data needed -->
175
+ <button gg-action="sign_out">Sign out</button>
176
+
177
+ <!-- Action with explicit data -->
178
+ <button gg-action="update_status" gg-action-data="status:archived">Archive</button>
179
+
180
+ <!-- Inside a data list, the action automatically receives the row's record -->
181
+ <ul gg-data-list="posts_list">
182
+ <li gg-list-template>
183
+ <h3 gg-field="title"></h3>
184
+ <button gg-action="delete_post">Delete</button>
185
+ </li>
186
+ </ul>
187
+ ```
188
+
189
+ When an action is inside a `gg-data` or `gg-data-list` container, it automatically receives the record as its data. Explicit `gg-action-data` values are merged on top (and win on conflict).
190
+
191
+ Action functions should return `{ ok: true }` or `{ ok: false, error }`:
192
+
193
+ ```js
194
+ app.addAction("delete_post", async (sb, { id }) => {
195
+ const { error } = await sb.from("posts").delete().eq("id", id);
196
+ return error ? { ok: false, error } : { ok: true };
197
+ });
198
+ ```
199
+
200
+ ## Config
201
+
202
+ ### `init(options)`
203
+
204
+ Returns an app instance with `addQuery`, `addAction`, and `start` methods.
205
+
206
+ | Option | Type | Required | Description |
207
+ |---|---|---|---|
208
+ | `supabaseUrl` | `string` | Yes | Your Supabase project URL |
209
+ | `supabaseKey` | `string` | Yes | Your Supabase publishable (anon) key |
210
+ | `auth` | `object` | No | Auth configuration (see below) |
211
+
212
+ ### Auth config
213
+
214
+ | Option | Type | Description |
215
+ |---|---|---|
216
+ | `auth.roleQuery` | `async (sb, userId) => string \| null` | Returns the user's role string. Called on auth state change. If omitted, `gg-auth` still works but `gg-role` is never set. |
217
+
218
+ Example:
219
+
220
+ ```js
221
+ const app = init({
222
+ supabaseUrl: "...",
223
+ supabaseKey: "...",
224
+ auth: {
225
+ roleQuery: async (sb, userId) => {
226
+ const { data } = await sb
227
+ .from("user_roles")
228
+ .select("role")
229
+ .eq("user_id", userId)
230
+ .single();
231
+ return data?.role ?? null;
232
+ },
233
+ },
234
+ });
235
+ ```
236
+
237
+ ### `app.addQuery(id, fn)`
238
+
239
+ Register a data query. `fn` receives `(sb)` and should return:
240
+ - A single object (or `null`) for use with `gg-data` or `gg-data-form`
241
+ - An array for use with `gg-data-list`
242
+
243
+ ### `app.addAction(id, fn)`
244
+
245
+ Register an action. `fn` receives `(sb, data)` and should return `{ ok: true }` or `{ ok: false, error }`.
246
+
247
+ ### `app.start()`
248
+
249
+ Initializes all engines and starts listening for DOM events. Call this after registering all queries and actions.
250
+
251
+ ## Exports
252
+
253
+ The library also exports utility functions for use in your queries and actions:
254
+
255
+ ```js
256
+ import {
257
+ init,
258
+ getPath, // resolve dot-paths on objects
259
+ populateFields, // set [gg-field] descendants from a record
260
+ setSwitchState, // write gg-switch-state on an element
261
+ applySwitchState, // toggle [gg-case] children to match state
262
+ setQueryParams, // programmatically set URL params
263
+ removeQueryParams, // programmatically remove URL params
264
+ } from "gg-wf-scripts";
265
+ ```
package/package.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "gg-wf-scripts",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "exports": "./src/index.js",
6
+ "dependencies": {
7
+ "@supabase/supabase-js": "^2"
8
+ }
9
+ }
@@ -0,0 +1,58 @@
1
+ import { actionRegistry } from "./actions.js";
2
+
3
+ function parseActionData(el) {
4
+ const attr = el.getAttribute("gg-action-data");
5
+ if (!attr) return {};
6
+ const data = {};
7
+ attr
8
+ .split(",")
9
+ .filter(Boolean)
10
+ .forEach((pair) => {
11
+ const [key, value] = pair.split(":");
12
+ if (key?.trim()) data[key.trim()] = value?.trim() ?? "";
13
+ });
14
+ return data;
15
+ }
16
+
17
+ function findRecord(el) {
18
+ let node = el.parentElement;
19
+ while (node) {
20
+ if (node.__ggRecord) return node.__ggRecord;
21
+ node = node.parentElement;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ export function initActionEngine(sb) {
27
+ async function handleAction(el) {
28
+ const id = el.getAttribute("gg-action");
29
+ const action = actionRegistry[id];
30
+ if (!action) {
31
+ console.warn(`[gg-action] no action registered for "${id}"`);
32
+ return;
33
+ }
34
+
35
+ const record = findRecord(el);
36
+ const explicit = parseActionData(el);
37
+ const data = record ? { ...record, ...explicit } : explicit;
38
+
39
+ try {
40
+ const result = await action(sb, data);
41
+ if (result?.ok === false) {
42
+ console.warn(`[gg-action] "${id}" failed:`, result.error ?? "unknown error");
43
+ }
44
+ } catch (err) {
45
+ console.error(`[gg-action] "${id}" threw:`, err);
46
+ }
47
+ }
48
+
49
+ document.addEventListener("click", (e) => {
50
+ const trigger = e.target.closest("[gg-action]");
51
+ if (trigger) handleAction(trigger);
52
+ });
53
+
54
+ document.addEventListener("gg:shadow:click", (e) => {
55
+ const trigger = e.detail.target.closest("[gg-action]");
56
+ if (trigger) handleAction(trigger);
57
+ });
58
+ }
package/src/actions.js ADDED
@@ -0,0 +1,13 @@
1
+ export const actionRegistry = {};
2
+
3
+ /**
4
+ * Register an action triggered by gg-action="<id>" on click.
5
+ *
6
+ * @param {string} id - The action identifier, referenced by gg-action="<id>" in markup.
7
+ * @param {(sb: SupabaseClient, data: object) => Promise<{ok: boolean, error?: any}>} fn
8
+ * Receives the Supabase client and a data object (merged from the nearest gg-data record
9
+ * and any explicit gg-action-data attribute). Return { ok: true } or { ok: false, error }.
10
+ */
11
+ export function registerAction(id, fn) {
12
+ actionRegistry[id] = fn;
13
+ }
package/src/auth.js ADDED
@@ -0,0 +1,28 @@
1
+ export async function initAuth(sb, roleQuery) {
2
+ async function applyAuthAttrs(userId) {
3
+ const body = document.body;
4
+ if (!userId) {
5
+ body.setAttribute("gg-auth", "false");
6
+ body.removeAttribute("gg-role");
7
+ return;
8
+ }
9
+ body.setAttribute("gg-auth", "true");
10
+ if (roleQuery) {
11
+ const role = await roleQuery(sb, userId);
12
+ if (role) {
13
+ body.setAttribute("gg-role", role);
14
+ } else {
15
+ body.removeAttribute("gg-role");
16
+ }
17
+ }
18
+ }
19
+
20
+ const {
21
+ data: { user },
22
+ } = await sb.auth.getUser();
23
+ applyAuthAttrs(user?.id ?? null);
24
+
25
+ sb.auth.onAuthStateChange((_event, session) => {
26
+ applyAuthAttrs(session?.user?.id ?? null);
27
+ });
28
+ }
package/src/bridges.js ADDED
@@ -0,0 +1,31 @@
1
+ import { setSwitchState } from "./helpers/dom.js";
2
+ import { onQueryChanged } from "./query-params.js";
3
+
4
+ export function initBridges() {
5
+ // ---- gg-switch-query: URL params → gg-switch-state ----
6
+ // On any URL param change, mirror its value onto matching
7
+ // [gg-switch-query="<key>"] elements' gg-switch-state.
8
+ onQueryChanged((key, value) => {
9
+ document
10
+ .querySelectorAll(`[gg-switch-query="${CSS.escape(key)}"]`)
11
+ .forEach((el) => setSwitchState(el, value));
12
+ });
13
+
14
+ // Initial-load pass: read current URL params and set state for every
15
+ // [gg-switch-query] on the page, so we don't flash before the first change.
16
+ const params = new URLSearchParams(window.location.search);
17
+ document.querySelectorAll("[gg-switch-query]").forEach((el) => {
18
+ const key = el.getAttribute("gg-switch-query");
19
+ setSwitchState(el, params.get(key));
20
+ });
21
+
22
+ // ---- webflow:emit → Webflow IX ----
23
+ window.addEventListener("load", () => {
24
+ Webflow.push(() => {
25
+ const wfIx = Webflow.require("ix3");
26
+ document.addEventListener("webflow:emit", (e) => {
27
+ wfIx.emit(e.detail.event);
28
+ });
29
+ });
30
+ });
31
+ }
@@ -0,0 +1,139 @@
1
+ import { getPath } from "./helpers/path.js";
2
+ import {
3
+ populateFields,
4
+ setSwitchState,
5
+ applySwitchState,
6
+ } from "./helpers/dom.js";
7
+ import { queryRegistry } from "./queries.js";
8
+ import { onQueryChanged } from "./query-params.js";
9
+
10
+ function applySwitchFields(root, record) {
11
+ root.querySelectorAll("[gg-switch-field]").forEach((el) => {
12
+ const path = el.getAttribute("gg-switch-field");
13
+ const value = getPath(record, path);
14
+ setSwitchState(el, value);
15
+ applySwitchState(el);
16
+ });
17
+ }
18
+
19
+ function populateFormFields(root, record) {
20
+ root.querySelectorAll(
21
+ "input[name], select[name], textarea[name]",
22
+ ).forEach((el) => {
23
+ const name = el.getAttribute("name");
24
+ const value = getPath(record, name);
25
+ if (value == null) return;
26
+
27
+ if (el instanceof HTMLInputElement) {
28
+ if (el.type === "checkbox") {
29
+ el.checked = Boolean(value);
30
+ } else if (el.type === "radio") {
31
+ el.checked = String(el.value) === String(value);
32
+ } else {
33
+ el.value = String(value);
34
+ }
35
+ } else {
36
+ el.value = String(value);
37
+ }
38
+
39
+ el.dispatchEvent(new Event("input", { bubbles: true }));
40
+ el.dispatchEvent(new Event("change", { bubbles: true }));
41
+ });
42
+ }
43
+
44
+ export function initDataEngine(sb) {
45
+ async function runQuery(container) {
46
+ const isList = container.hasAttribute("gg-data-list");
47
+ const isForm = container.hasAttribute("gg-data-form");
48
+ const id = container.getAttribute(
49
+ isList ? "gg-data-list" : isForm ? "gg-data-form" : "gg-data",
50
+ );
51
+ const query = queryRegistry[id];
52
+ if (!query) {
53
+ console.warn(`[gg-data] no query registered for "${id}"`);
54
+ return;
55
+ }
56
+
57
+ try {
58
+ const result = await query(sb);
59
+ if (isList) {
60
+ if (!Array.isArray(result)) {
61
+ console.warn(
62
+ `[gg-data-list] query "${id}" did not return an array`,
63
+ );
64
+ return;
65
+ }
66
+
67
+ const template = container.querySelector("[gg-list-template]");
68
+ if (!template) {
69
+ console.warn(
70
+ `[gg-data-list] no [gg-list-template] inside "${id}"`,
71
+ );
72
+ return;
73
+ }
74
+
75
+ Array.from(container.children).forEach((child) => {
76
+ if (child !== template) child.remove();
77
+ });
78
+
79
+ result.forEach((record) => {
80
+ const clone = template.cloneNode(true);
81
+ clone.removeAttribute("gg-list-template");
82
+ clone.setAttribute(
83
+ "gg-query-set",
84
+ `modal:view,id:${record.id}`,
85
+ );
86
+ clone.style.display = "flex";
87
+ if (record?.id != null) clone.id = String(record.id);
88
+ clone.__ggRecord = record;
89
+ populateFields(clone, record);
90
+ applySwitchFields(clone, record);
91
+ container.appendChild(clone);
92
+ });
93
+ } else if (isForm) {
94
+ if (Array.isArray(result)) {
95
+ console.warn(
96
+ `[gg-data-form] query "${id}" returned an array; expected a single record`,
97
+ );
98
+ return;
99
+ }
100
+ if (!result) return;
101
+ container.__ggRecord = result;
102
+ populateFormFields(container, result);
103
+ } else {
104
+ if (Array.isArray(result)) {
105
+ console.warn(
106
+ `[gg-data] query "${id}" returned an array; use gg-data-list instead`,
107
+ );
108
+ return;
109
+ }
110
+ if (!result) return;
111
+ container.__ggRecord = result;
112
+ populateFields(container, result);
113
+ applySwitchFields(container, result);
114
+ }
115
+ } catch (err) {
116
+ console.error(`[gg-data] query "${id}" failed:`, err);
117
+ }
118
+ }
119
+
120
+ // Run all data containers on load
121
+ document
122
+ .querySelectorAll("[gg-data], [gg-data-list], [gg-data-form]")
123
+ .forEach(runQuery);
124
+
125
+ // Re-run queries when matching URL params change
126
+ onQueryChanged((key) => {
127
+ document
128
+ .querySelectorAll(
129
+ "[gg-data][gg-data-on], [gg-data-list][gg-data-on], [gg-data-form][gg-data-on]",
130
+ )
131
+ .forEach((c) => {
132
+ const keys = c
133
+ .getAttribute("gg-data-on")
134
+ .split(",")
135
+ .map((s) => s.trim());
136
+ if (keys.includes(key)) runQuery(c);
137
+ });
138
+ });
139
+ }
package/src/dialog.js ADDED
@@ -0,0 +1,86 @@
1
+ import { removeQueryParams, onQueryChanged } from "./query-params.js";
2
+
3
+ function stopLenis() {
4
+ if (typeof lenis !== "undefined") lenis.stop();
5
+ }
6
+
7
+ function startLenis() {
8
+ if (typeof lenis !== "undefined") lenis.start();
9
+ }
10
+
11
+ function openDialog() {
12
+ const dialog = document.querySelector("dialog");
13
+ if (!dialog) return;
14
+ dialog.removeAttribute("aria-hidden");
15
+ dialog.removeAttribute("inert");
16
+ dialog.showModal();
17
+ stopLenis();
18
+ }
19
+
20
+ function closeDialog() {
21
+ const dialog = document.querySelector("dialog");
22
+ if (!dialog) return;
23
+ dialog.setAttribute("aria-hidden", "true");
24
+ dialog.setAttribute("inert", "");
25
+ dialog.close();
26
+ startLenis();
27
+ }
28
+
29
+ function dismissViaUrlOrDirect() {
30
+ const modalParam = new URLSearchParams(window.location.search).get("modal");
31
+ if (modalParam) {
32
+ removeQueryParams(["modal", "id"]);
33
+ } else {
34
+ closeDialog();
35
+ }
36
+ }
37
+
38
+ function syncDialogToUrl() {
39
+ const modalParam = new URLSearchParams(window.location.search).get("modal");
40
+ if (modalParam) {
41
+ openDialog();
42
+ } else {
43
+ closeDialog();
44
+ }
45
+ }
46
+
47
+ export function initDialog() {
48
+ // Subscribe to query param changes — open/close when "modal" changes
49
+ onQueryChanged((key, value) => {
50
+ if (key !== "modal") return;
51
+ if (value) {
52
+ openDialog();
53
+ } else {
54
+ closeDialog();
55
+ }
56
+ });
57
+
58
+ // Inbound events from external code
59
+ document.addEventListener("gg:dialog:open", openDialog);
60
+ document.addEventListener("gg:dialog:close", closeDialog);
61
+
62
+ // Backdrop click
63
+ document.addEventListener("click", (e) => {
64
+ if (!e.target.matches("dialog[open]")) return;
65
+ const rect = e.target.getBoundingClientRect();
66
+ const outside =
67
+ e.clientX < rect.left ||
68
+ e.clientX > rect.right ||
69
+ e.clientY < rect.top ||
70
+ e.clientY > rect.bottom;
71
+ if (outside) dismissViaUrlOrDirect();
72
+ });
73
+
74
+ // Escape key — preempt the default close so the URL stays source of truth
75
+ document.addEventListener("cancel", (e) => {
76
+ if (!e.target.matches("dialog")) return;
77
+ e.preventDefault();
78
+ dismissViaUrlOrDirect();
79
+ });
80
+
81
+ // Back button
82
+ window.addEventListener("popstate", syncDialogToUrl);
83
+
84
+ // Initial load
85
+ syncDialogToUrl();
86
+ }
@@ -0,0 +1,122 @@
1
+ const TRANSITION_MS = 200;
2
+
3
+ function parseConditions(attr) {
4
+ return attr
5
+ .split(",")
6
+ .map((pair) => {
7
+ const [name, value] = pair.split(":");
8
+ return { name: name?.trim(), value: value?.trim() };
9
+ })
10
+ .filter((p) => p.name && p.value);
11
+ }
12
+
13
+ function getFieldValue(scope, name) {
14
+ const escaped = CSS.escape(name);
15
+ const inputs = scope.querySelectorAll(
16
+ `input[name="${escaped}"], select[name="${escaped}"], textarea[name="${escaped}"]`,
17
+ );
18
+ if (!inputs.length) return null;
19
+
20
+ const type = (inputs[0].getAttribute("type") || "").toLowerCase();
21
+ if (type === "radio" || type === "checkbox") {
22
+ for (const input of inputs) {
23
+ if (input.checked) return input.value;
24
+ }
25
+ return null;
26
+ }
27
+ return inputs[0].value.trim();
28
+ }
29
+
30
+ function matchesAny(scope, conditions) {
31
+ return conditions.some(
32
+ ({ name, value }) => getFieldValue(scope, name) === value,
33
+ );
34
+ }
35
+
36
+ export function initFormVisibility() {
37
+ // Group all [gg-visible-when] elements by their nearest scope —
38
+ // either an explicit [gg-form-scope] or a <form>.
39
+ const scopeTargets = new Map();
40
+ document.querySelectorAll("[gg-visible-when]").forEach((el) => {
41
+ if (!Boolean(el.getAttribute("gg-visible-when"))) {
42
+ return;
43
+ }
44
+ const scope = el.closest("[gg-form-scope], form");
45
+ if (!scope) {
46
+ console.warn(
47
+ "[gg-visible-when] element is not inside a <form> or [gg-form-scope]:",
48
+ el,
49
+ );
50
+ return;
51
+ }
52
+ if (!scopeTargets.has(scope)) scopeTargets.set(scope, []);
53
+ scopeTargets.get(scope).push(el);
54
+ });
55
+
56
+ scopeTargets.forEach((targets, scope) => {
57
+ const conditions = new WeakMap();
58
+ const lastState = new WeakMap();
59
+ const hideTimers = new WeakMap();
60
+
61
+ targets.forEach((el) => {
62
+ conditions.set(
63
+ el,
64
+ parseConditions(el.getAttribute("gg-visible-when")),
65
+ );
66
+ el.style.transition = "none";
67
+ el.style.opacity = "0";
68
+ el.style.display = "none";
69
+ el.style.pointerEvents = "none";
70
+ el.setAttribute("inert", "");
71
+ el.setAttribute("aria-hidden", "true");
72
+ lastState.set(el, false);
73
+ });
74
+
75
+ function show(el) {
76
+ const pending = hideTimers.get(el);
77
+ if (pending) {
78
+ clearTimeout(pending);
79
+ hideTimers.delete(el);
80
+ }
81
+ el.removeAttribute("inert");
82
+ el.removeAttribute("aria-hidden");
83
+ el.style.display = "";
84
+ el.style.pointerEvents = "";
85
+ requestAnimationFrame(() => {
86
+ el.style.opacity = "1";
87
+ });
88
+ }
89
+
90
+ function hide(el) {
91
+ el.style.opacity = "0";
92
+ el.style.pointerEvents = "none";
93
+ el.setAttribute("inert", "");
94
+ el.setAttribute("aria-hidden", "true");
95
+ const t = setTimeout(() => {
96
+ el.style.display = "none";
97
+ hideTimers.delete(el);
98
+ }, TRANSITION_MS);
99
+ hideTimers.set(el, t);
100
+ }
101
+
102
+ function evaluate() {
103
+ targets.forEach((el) => {
104
+ const shouldShow = matchesAny(scope, conditions.get(el));
105
+ if (shouldShow === lastState.get(el)) return;
106
+ lastState.set(el, shouldShow);
107
+ if (shouldShow) show(el);
108
+ else hide(el);
109
+ });
110
+ }
111
+
112
+ requestAnimationFrame(() => {
113
+ targets.forEach((el) => {
114
+ el.style.transition = `opacity ${TRANSITION_MS}ms ease`;
115
+ });
116
+ evaluate();
117
+ });
118
+
119
+ scope.addEventListener("change", evaluate);
120
+ scope.addEventListener("input", evaluate);
121
+ });
122
+ }
@@ -0,0 +1,35 @@
1
+ import { getPath } from "./path.js";
2
+
3
+ /**
4
+ * Set textContent on every [gg-field] descendant of `root` to the value
5
+ * at that field's dot-path on `record`. Null / missing values are left
6
+ * as-is (keeps whatever the markup's default content was).
7
+ */
8
+ export function populateFields(root, record) {
9
+ root.querySelectorAll("[gg-field]").forEach((el) => {
10
+ const path = el.getAttribute("gg-field");
11
+ const value = getPath(record, path);
12
+ if (value != null) el.textContent = String(value);
13
+ });
14
+ }
15
+
16
+ /**
17
+ * Write a normalized switch state onto an element.
18
+ * null / undefined become "" so gg-case="" can serve as the empty/default.
19
+ */
20
+ export function setSwitchState(el, value) {
21
+ el.setAttribute("gg-switch-state", value == null ? "" : String(value));
22
+ }
23
+
24
+ /**
25
+ * Show the [gg-case] child whose value matches the container's current
26
+ * gg-switch-state; hide the rest with display:none.
27
+ */
28
+ export function applySwitchState(container) {
29
+ const state = container.getAttribute("gg-switch-state") ?? "";
30
+ Array.from(container.children).forEach((child) => {
31
+ if (!child.hasAttribute("gg-case")) return;
32
+ const match = child.getAttribute("gg-case") === state;
33
+ child.style.display = match ? "" : "none";
34
+ });
35
+ }
@@ -0,0 +1,4 @@
1
+ /** Resolve a dot-path on an object, e.g. getPath(row, "school.name"). */
2
+ export function getPath(obj, path) {
3
+ return path.split(".").reduce((acc, key) => acc?.[key], obj);
4
+ }
package/src/index.js ADDED
@@ -0,0 +1,52 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+ import { registerQuery } from "./queries.js";
3
+ import { registerAction } from "./actions.js";
4
+ import { setSwitchState, applySwitchState, populateFields } from "./helpers/dom.js";
5
+ import { setQueryParams, removeQueryParams, initQueryParams } from "./query-params.js";
6
+ import { initAuth } from "./auth.js";
7
+ import { initSwitchEngine } from "./switch-engine.js";
8
+ import { initDialog } from "./dialog.js";
9
+ import { initBridges } from "./bridges.js";
10
+ import { initFormVisibility } from "./form-visibility.js";
11
+ import { initDataEngine } from "./data-engine.js";
12
+ import { initActionEngine } from "./action-engine.js";
13
+
14
+ export { setSwitchState, applySwitchState, populateFields, setQueryParams, removeQueryParams };
15
+ export { getPath } from "./helpers/path.js";
16
+
17
+ /**
18
+ * Create a gg-scripts app instance.
19
+ *
20
+ * @param {object} options
21
+ * @param {string} options.supabaseUrl - Your Supabase project URL.
22
+ * @param {string} options.supabaseKey - Your Supabase publishable (anon) key.
23
+ * @param {object} [options.auth] - Auth configuration.
24
+ * @param {(sb: SupabaseClient, userId: string) => Promise<string|null>} [options.auth.roleQuery]
25
+ * Returns the user's role string for gg-role gating. If omitted, gg-role is never set.
26
+ * @returns {{ addQuery: function, addAction: function, start: function }}
27
+ */
28
+ export function init({ supabaseUrl, supabaseKey, auth }) {
29
+ return {
30
+ addQuery: registerQuery,
31
+ addAction: registerAction,
32
+ start() {
33
+ function run() {
34
+ const sb = createClient(supabaseUrl, supabaseKey);
35
+ initAuth(sb, auth?.roleQuery);
36
+ initSwitchEngine();
37
+ initQueryParams();
38
+ initDialog();
39
+ initBridges();
40
+ initFormVisibility();
41
+ initDataEngine(sb);
42
+ initActionEngine(sb);
43
+ }
44
+
45
+ if (document.readyState === "loading") {
46
+ document.addEventListener("DOMContentLoaded", run);
47
+ } else {
48
+ run();
49
+ }
50
+ },
51
+ };
52
+ }
package/src/queries.js ADDED
@@ -0,0 +1,12 @@
1
+ export const queryRegistry = {};
2
+
3
+ /**
4
+ * Register a data query for use with gg-data, gg-data-form, or gg-data-list.
5
+ *
6
+ * @param {string} id - The query identifier, referenced by gg-data="<id>" in markup.
7
+ * @param {(sb: SupabaseClient) => Promise<object|object[]|null>} fn
8
+ * Return a single object (or null) for gg-data/gg-data-form, or an array for gg-data-list.
9
+ */
10
+ export function registerQuery(id, fn) {
11
+ queryRegistry[id] = fn;
12
+ }
@@ -0,0 +1,77 @@
1
+ const subscribers = [];
2
+
3
+ export function onQueryChanged(callback) {
4
+ subscribers.push(callback);
5
+ return () => {
6
+ const idx = subscribers.indexOf(callback);
7
+ if (idx >= 0) subscribers.splice(idx, 1);
8
+ };
9
+ }
10
+
11
+ function notify(key, value) {
12
+ subscribers.forEach((cb) => cb(key, value));
13
+ }
14
+
15
+ /**
16
+ * Set one or more URL query params and notify subscribers.
17
+ *
18
+ * @param {Array<{key: string, value: string}>} params - Key/value pairs to set.
19
+ */
20
+ export function setQueryParams(params) {
21
+ const url = new URL(window.location);
22
+ params.forEach(({ key, value }) => url.searchParams.set(key, value));
23
+ history.pushState({}, "", url);
24
+ params.forEach(({ key, value }) => notify(key, value));
25
+ }
26
+
27
+ /**
28
+ * Remove one or more URL query params and notify subscribers.
29
+ *
30
+ * @param {string[]} keys - Param keys to remove.
31
+ */
32
+ export function removeQueryParams(keys) {
33
+ const url = new URL(window.location);
34
+ keys.forEach((key) => url.searchParams.delete(key));
35
+ history.pushState({}, "", url);
36
+ keys.forEach((key) => notify(key, null));
37
+ }
38
+
39
+ // ---- click delegation for gg-query-set / gg-query-remove ----
40
+
41
+ function handleQueryClick(target) {
42
+ const setTrigger = target.closest("[gg-query-set]");
43
+ if (setTrigger) {
44
+ const params = setTrigger
45
+ .getAttribute("gg-query-set")
46
+ .split(",")
47
+ .filter(Boolean)
48
+ .map((pair) => {
49
+ const [key, value] = pair.split(":");
50
+ return { key: key?.trim(), value: value?.trim() };
51
+ })
52
+ .filter((p) => p.key && p.value);
53
+ if (params.length) setQueryParams(params);
54
+ return;
55
+ }
56
+
57
+ const removeTrigger = target.closest("[gg-query-remove]");
58
+ if (removeTrigger) {
59
+ const keys = removeTrigger
60
+ .getAttribute("gg-query-remove")
61
+ .split(",")
62
+ .map((k) => k.trim())
63
+ .filter(Boolean);
64
+ if (keys.length) removeQueryParams(keys);
65
+ return;
66
+ }
67
+ }
68
+
69
+ export function initQueryParams() {
70
+ document.addEventListener("click", (e) => handleQueryClick(e.target));
71
+
72
+ // Shadow-root click forwarder — shadow DOM swallows bubbling, so anything
73
+ // inside a shadow tree dispatches `gg:shadow:click` with the real target.
74
+ document.addEventListener("gg:shadow:click", (e) =>
75
+ handleQueryClick(e.detail.target),
76
+ );
77
+ }
@@ -0,0 +1,22 @@
1
+ import { applySwitchState } from "./helpers/dom.js";
2
+
3
+ export function initSwitchEngine() {
4
+ // Re-apply whenever gg-switch-state changes on any element.
5
+ new MutationObserver((mutations) => {
6
+ mutations.forEach((m) => {
7
+ if (m.attributeName === "gg-switch-state") {
8
+ applySwitchState(m.target);
9
+ }
10
+ });
11
+ }).observe(document.body, {
12
+ attributes: true,
13
+ attributeFilter: ["gg-switch-state"],
14
+ subtree: true,
15
+ });
16
+
17
+ // Apply initial state for any elements that already have gg-switch-state
18
+ // set at load time, so we don't flash all children before the first mutation.
19
+ document
20
+ .querySelectorAll("[gg-switch-state]")
21
+ .forEach(applySwitchState);
22
+ }