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 +265 -0
- package/package.json +9 -0
- package/src/action-engine.js +58 -0
- package/src/actions.js +13 -0
- package/src/auth.js +28 -0
- package/src/bridges.js +31 -0
- package/src/data-engine.js +139 -0
- package/src/dialog.js +86 -0
- package/src/form-visibility.js +122 -0
- package/src/helpers/dom.js +35 -0
- package/src/helpers/path.js +4 -0
- package/src/index.js +52 -0
- package/src/queries.js +12 -0
- package/src/query-params.js +77 -0
- package/src/switch-engine.js +22 -0
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,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
|
+
}
|
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
|
+
}
|