@jarenjs/app 0.34.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 +197 -0
- package/dist/types/actions.d.ts +89 -0
- package/dist/types/app.d.ts +293 -0
- package/dist/types/diagnostics.d.ts +67 -0
- package/dist/types/docstore.d.ts +52 -0
- package/dist/types/errors.d.ts +178 -0
- package/dist/types/focus.d.ts +89 -0
- package/dist/types/forms.d.ts +122 -0
- package/dist/types/index.d.ts +14 -0
- package/dist/types/splitter.d.ts +37 -0
- package/dist/types/tasks.d.ts +129 -0
- package/docs/APP-FORMAT.md +804 -0
- package/docs/TASKS.md +254 -0
- package/package.json +57 -0
- package/schemas/jaren-app.draft-07.schema.json +86 -0
- package/schemas/jaren-app.schema.json +86 -0
- package/src/actions.js +167 -0
- package/src/app.js +1622 -0
- package/src/diagnostics.js +77 -0
- package/src/docstore.js +71 -0
- package/src/errors.js +245 -0
- package/src/focus.js +188 -0
- package/src/forms.js +325 -0
- package/src/index.js +16 -0
- package/src/splitter.js +113 -0
- package/src/tasks.js +299 -0
package/src/forms.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The standard form rules — the shipped JSLT rule set that renders
|
|
4
|
+
* any `@jarenjs/forms` view model (`buildFormViewModel`) to vnodes, and
|
|
5
|
+
* the standard action documents that write user input back into the
|
|
6
|
+
* state. Everything both factories return is PLAIN JSON: no functions,
|
|
7
|
+
* no imports from forms — the rules dispatch on the view-model *shape*
|
|
8
|
+
* (JSONPath filter selectors on `control`), which is the whole point of
|
|
9
|
+
* the format stack.
|
|
10
|
+
*
|
|
11
|
+
* Wiring (see the README for the complete walkthrough):
|
|
12
|
+
*
|
|
13
|
+
* view: [...createFormView(), { match: '$', body: [..., { $apply: '$.form' }] }]
|
|
14
|
+
* actions: { ...createFormActions({ dataPointer: '/data' }) }
|
|
15
|
+
* options: { viewModel: (state) => ({ form: buildFormViewModel(model, state.data, { rules }) }) }
|
|
16
|
+
*
|
|
17
|
+
* A DOM control's value is a STRING, and two of these controls carry
|
|
18
|
+
* something else: a select over a non-string enum, and the `json`
|
|
19
|
+
* editor over an arbitrary value. Both round-trip through the JSON text
|
|
20
|
+
* the view model precomputes (`option.key`) or the operator types, and
|
|
21
|
+
* both decode it in a registered event-field extractor —
|
|
22
|
+
* {@link formEventFields}, the format's one sanctioned place for host
|
|
23
|
+
* JavaScript at the DOM boundary (APP-FORMAT §5.4). A host that renders
|
|
24
|
+
* these controls MUST register them.
|
|
25
|
+
*
|
|
26
|
+
* Remaining limitation, documented rather than hidden: a cleared number
|
|
27
|
+
* input writes `null` (which surfaces as a validation error, not a
|
|
28
|
+
* dispatch error).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The default action names shared by both factories. */
|
|
32
|
+
const DEFAULT_ACTIONS = Object.freeze({
|
|
33
|
+
input: 'form/input',
|
|
34
|
+
check: 'form/check',
|
|
35
|
+
number: 'form/number',
|
|
36
|
+
json: 'form/json',
|
|
37
|
+
add: 'form/add',
|
|
38
|
+
remove: 'form/remove',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The `$event` field name both JSON-carrying controls request.
|
|
43
|
+
* @see formEventFields
|
|
44
|
+
*/
|
|
45
|
+
const JSON_FIELD = 'formJsonValue';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The event-field extractors the standard form controls need, for
|
|
49
|
+
* `createApp`'s `eventFields` (APP-FORMAT §5.4).
|
|
50
|
+
*
|
|
51
|
+
* One extractor, `formJsonValue`: the control's value parsed as JSON.
|
|
52
|
+
* A select carries `option.key` (the view model's JSON text for the
|
|
53
|
+
* typed enum value) and the `json` editor carries whatever the operator
|
|
54
|
+
* typed. Unparsable text yields `null` rather than throwing, so a
|
|
55
|
+
* half-typed JSON document is a validation problem — visible, fixable —
|
|
56
|
+
* instead of a dispatch error; `json` fields therefore want a schema
|
|
57
|
+
* that rejects `null` if absence is not acceptable.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* createApp(doc, { node, eventFields: { ...formEventFields() } });
|
|
61
|
+
*
|
|
62
|
+
* @returns {Record<string, (event: any) => any>}
|
|
63
|
+
*/
|
|
64
|
+
export function formEventFields() {
|
|
65
|
+
return {
|
|
66
|
+
[JSON_FIELD]: (event) => {
|
|
67
|
+
const raw = event?.target?.value;
|
|
68
|
+
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(raw);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Options shared by `createFormView` / `createFormActions`.
|
|
81
|
+
* @typedef {Object} FormViewOptions
|
|
82
|
+
* @property {string} [root] - JSONPath of the form view-model node inside
|
|
83
|
+
* the view input document (default `'$.form'`).
|
|
84
|
+
* @property {string} [classPrefix] - CSS class prefix (default `'jaren-form'`).
|
|
85
|
+
* @property {Record<string, string>} [actions] - Overrides for the
|
|
86
|
+
* standard action names (`input`/`check`/`number`/`add`/`remove`).
|
|
87
|
+
* @property {string} [addLabel] - Add-item button text (default `'+'`).
|
|
88
|
+
* @property {string} [removeLabel] - Remove-item button text (default `'×'`).
|
|
89
|
+
* @property {{addItem?: string, removeItem?: string}} [labels] - Accessible
|
|
90
|
+
* names for the two symbol buttons. `@jarenjs/forms`'
|
|
91
|
+
* `formChromeLabels(catalog)` resolves them from a message catalog;
|
|
92
|
+
* the English defaults apply when absent.
|
|
93
|
+
* @property {string} [dataPointer] - (actions) JSON Pointer to the form
|
|
94
|
+
* data inside the app state (default `'/data'`).
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The standard form rule set: a JSLT rule array rendering a
|
|
99
|
+
* `buildFormViewModel` tree. Concatenate it into an app's view
|
|
100
|
+
* stylesheet and `{"$apply": "<root>"}` the form node from a page rule.
|
|
101
|
+
*
|
|
102
|
+
* @param {FormViewOptions} [options]
|
|
103
|
+
* @returns {any[]} A JSLT rule array (plain JSON).
|
|
104
|
+
*/
|
|
105
|
+
export function createFormView(options = {}) {
|
|
106
|
+
const root = options.root ?? '$.form';
|
|
107
|
+
const cls = options.classPrefix ?? 'jaren-form';
|
|
108
|
+
const act = { ...DEFAULT_ACTIONS, ...options.actions };
|
|
109
|
+
const labels = { addItem: 'Add item', removeItem: 'Remove item', ...options.labels };
|
|
110
|
+
/** Match any view-model node under `root` with the given control. */
|
|
111
|
+
const ctl = (control) => `${root}..[?@.control == '${control}']`;
|
|
112
|
+
|
|
113
|
+
const disabled = { $not: '$.enabled' };
|
|
114
|
+
|
|
115
|
+
/** The shared field chrome around one control vnode. */
|
|
116
|
+
const field = (control) => ['div', { class: `${cls}-field`, 'data-pointer': '$.pointer' },
|
|
117
|
+
['label', {},
|
|
118
|
+
'$.label',
|
|
119
|
+
{ $if: ['$.required', ['span', { class: `${cls}-required`, 'aria-hidden': 'true' }, ' *']] },
|
|
120
|
+
control,
|
|
121
|
+
],
|
|
122
|
+
{ $if: ['$.description', ['p', { class: `${cls}-description` }, '$.description']] },
|
|
123
|
+
[{ $apply: '$.errors[*]' }],
|
|
124
|
+
{ $if: ['$.removable',
|
|
125
|
+
['button', {
|
|
126
|
+
type: 'button',
|
|
127
|
+
class: `${cls}-remove`,
|
|
128
|
+
// the glyph is decoration; the accessible name is the label
|
|
129
|
+
'aria-label': labels.removeItem,
|
|
130
|
+
title: labels.removeItem,
|
|
131
|
+
on: { click: { action: act.remove, with: { pointer: '$.pointer' } } },
|
|
132
|
+
}, options.removeLabel ?? '×']] },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
// every write binding carries the element flag: the standard actions
|
|
136
|
+
// pick RFC 6902 `replace` for array elements (where `add` would
|
|
137
|
+
// insert) and `add` for object members (set-or-replace)
|
|
138
|
+
const writeWith = { pointer: '$.pointer', element: '$.element' };
|
|
139
|
+
|
|
140
|
+
/** A typed `<input>` control with the standard input binding. */
|
|
141
|
+
const textInput = (type, action) => ['input', {
|
|
142
|
+
type,
|
|
143
|
+
value: '$.value',
|
|
144
|
+
placeholder: '$.placeholder',
|
|
145
|
+
readonly: '$.readOnly',
|
|
146
|
+
disabled,
|
|
147
|
+
on: { input: { action, with: writeWith } },
|
|
148
|
+
}];
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A date-family `<input>`: the text-input binding plus the schema's
|
|
152
|
+
* date bounds as `min`/`max`, so the picker itself refuses an
|
|
153
|
+
* out-of-range date instead of the user finding out on submit. An
|
|
154
|
+
* absent bound evaluates to the empty sequence, which omits the
|
|
155
|
+
* attribute. HTML has no exclusive date bounds, so
|
|
156
|
+
* `formatExclusive*` stays a submit-time check.
|
|
157
|
+
*/
|
|
158
|
+
const dateInput = (type, action) => ['input', {
|
|
159
|
+
type,
|
|
160
|
+
value: '$.value',
|
|
161
|
+
placeholder: '$.placeholder',
|
|
162
|
+
readonly: '$.readOnly',
|
|
163
|
+
disabled,
|
|
164
|
+
min: '$.constraints.formatMinimum',
|
|
165
|
+
max: '$.constraints.formatMaximum',
|
|
166
|
+
on: { input: { action, with: writeWith } },
|
|
167
|
+
}];
|
|
168
|
+
|
|
169
|
+
const rules = [
|
|
170
|
+
// the form root: a section holding the top-level fields
|
|
171
|
+
{
|
|
172
|
+
match: root,
|
|
173
|
+
body: ['section', { class: cls },
|
|
174
|
+
{ $if: ['$.label', ['h3', { class: `${cls}-title` }, '$.label']] },
|
|
175
|
+
[{ $apply: '$.children[*]' }],
|
|
176
|
+
[{ $apply: '$.items[*]' }],
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
// nested objects: a fieldset group
|
|
180
|
+
{
|
|
181
|
+
match: ctl('object'),
|
|
182
|
+
body: ['fieldset', { class: `${cls}-group`, 'data-pointer': '$.pointer' },
|
|
183
|
+
{ $if: ['$.label', ['legend', {}, '$.label']] },
|
|
184
|
+
[{ $apply: '$.children[*]' }],
|
|
185
|
+
[{ $apply: '$.errors[*]' }],
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
// arrays: expanded items plus the add-item button
|
|
189
|
+
{
|
|
190
|
+
match: ctl('array'),
|
|
191
|
+
body: ['fieldset', { class: `${cls}-array`, 'data-pointer': '$.pointer' },
|
|
192
|
+
{ $if: ['$.label', ['legend', {}, '$.label']] },
|
|
193
|
+
[{ $apply: '$.items[*]' }],
|
|
194
|
+
{ $if: [{ $exists: '$.addValue' },
|
|
195
|
+
['button', {
|
|
196
|
+
type: 'button',
|
|
197
|
+
class: `${cls}-add`,
|
|
198
|
+
'aria-label': labels.addItem,
|
|
199
|
+
title: labels.addItem,
|
|
200
|
+
on: { click: { action: act.add, with: { pointer: '$.pointer', value: '$.addValue' } } },
|
|
201
|
+
}, options.addLabel ?? '+']] },
|
|
202
|
+
[{ $apply: '$.errors[*]' }],
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
// one error line per message
|
|
206
|
+
{
|
|
207
|
+
match: `${root}..errors[*]`,
|
|
208
|
+
body: ['p', { class: `${cls}-error`, role: 'alert' }, '$'],
|
|
209
|
+
},
|
|
210
|
+
// select: options are precomputed by the view model, and carry the
|
|
211
|
+
// JSON text of their typed value so the round trip survives the DOM
|
|
212
|
+
{
|
|
213
|
+
match: `${root}..options[*]`,
|
|
214
|
+
body: ['option', { value: '$.key', selected: '$.selected' }, '$.label'],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
match: ctl('select'),
|
|
218
|
+
body: field(['select', {
|
|
219
|
+
disabled,
|
|
220
|
+
on: {
|
|
221
|
+
change: { action: act.json, with: writeWith, event: [JSON_FIELD] },
|
|
222
|
+
},
|
|
223
|
+
}, [{ $apply: '$.options[*]' }]]),
|
|
224
|
+
},
|
|
225
|
+
// boolean: checkbox with the checked binding
|
|
226
|
+
{
|
|
227
|
+
match: ctl('checkbox'),
|
|
228
|
+
body: field(['input', {
|
|
229
|
+
type: 'checkbox',
|
|
230
|
+
checked: '$.value',
|
|
231
|
+
disabled,
|
|
232
|
+
on: { change: { action: act.check, with: writeWith } },
|
|
233
|
+
}]),
|
|
234
|
+
},
|
|
235
|
+
// free text: textarea with the value as its text child
|
|
236
|
+
{
|
|
237
|
+
match: ctl('textarea'),
|
|
238
|
+
body: field(['textarea', {
|
|
239
|
+
placeholder: '$.placeholder',
|
|
240
|
+
readonly: '$.readOnly',
|
|
241
|
+
disabled,
|
|
242
|
+
on: { input: { action: act.input, with: writeWith } },
|
|
243
|
+
}, '$.value']),
|
|
244
|
+
},
|
|
245
|
+
// numbers: coerced by the standard number action
|
|
246
|
+
{ match: ctl('number'), body: field(textInput('number', act.number)) },
|
|
247
|
+
// fixed values render as text
|
|
248
|
+
{ match: ctl('const'), body: field(['span', { class: `${cls}-const` }, '$.value']) },
|
|
249
|
+
// structured values: a JSON text editor. The view model precomputes
|
|
250
|
+
// the text (`json`), so the control needs no encoder of its own.
|
|
251
|
+
{
|
|
252
|
+
match: ctl('json'),
|
|
253
|
+
body: field(['textarea', {
|
|
254
|
+
class: `${cls}-json`,
|
|
255
|
+
rows: 4,
|
|
256
|
+
spellcheck: 'false',
|
|
257
|
+
readonly: '$.readOnly',
|
|
258
|
+
disabled,
|
|
259
|
+
on: { change: { action: act.json, with: writeWith, event: [JSON_FIELD] } },
|
|
260
|
+
}, '$.json']),
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
// the text-input family: one rule per control, all through act.input
|
|
265
|
+
for (const type of ['text', 'email', 'url', 'password', 'color']) {
|
|
266
|
+
rules.push({ match: ctl(type), body: field(textInput(type, act.input)) });
|
|
267
|
+
}
|
|
268
|
+
// the date family, which additionally carries its bounds
|
|
269
|
+
for (const type of ['date', 'datetime-local', 'time']) {
|
|
270
|
+
rules.push({ match: ctl(type), body: field(dateInput(type, act.input)) });
|
|
271
|
+
}
|
|
272
|
+
return rules;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The standard form actions: named query documents that write user
|
|
277
|
+
* input into the form data at `dataPointer + payload.pointer`. RFC 6902
|
|
278
|
+
* `add` is set-or-replace for object members, so untouched (absent)
|
|
279
|
+
* fields are created on first input.
|
|
280
|
+
*
|
|
281
|
+
* @param {FormViewOptions} [options]
|
|
282
|
+
* @returns {Record<string, any>} An `actions` fragment (plain JSON) to
|
|
283
|
+
* spread into an app document.
|
|
284
|
+
*/
|
|
285
|
+
export function createFormActions(options = {}) {
|
|
286
|
+
const dataPointer = options.dataPointer ?? '/data';
|
|
287
|
+
const act = { ...DEFAULT_ACTIONS, ...options.actions };
|
|
288
|
+
const target = { $concat: [dataPointer, '$payload.pointer'] };
|
|
289
|
+
// RFC 6902: `add` on an array index INSERTS (shifting later elements);
|
|
290
|
+
// element nodes must be written with `replace` instead. The view-model
|
|
291
|
+
// `element` flag travels through the binding payload.
|
|
292
|
+
const writeOp = { $if: ['$payload.element', 'replace', 'add'] };
|
|
293
|
+
return {
|
|
294
|
+
[act.input]: {
|
|
295
|
+
patch: [{ op: writeOp, path: target, value: '$event.value' }],
|
|
296
|
+
},
|
|
297
|
+
[act.check]: {
|
|
298
|
+
patch: [{ op: writeOp, path: target, value: '$event.checked' }],
|
|
299
|
+
},
|
|
300
|
+
[act.number]: {
|
|
301
|
+
patch: [{
|
|
302
|
+
op: writeOp,
|
|
303
|
+
path: target,
|
|
304
|
+
// a cleared input writes null: a visible validation problem, not
|
|
305
|
+
// a dispatch error
|
|
306
|
+
value: { $if: [{ $ne: ['$event.value', ''] }, { $number: '$event.value' }, null] },
|
|
307
|
+
}],
|
|
308
|
+
},
|
|
309
|
+
// the two JSON-carrying controls (typed select, json editor) share
|
|
310
|
+
// one action: the extractor already produced a JSON value
|
|
311
|
+
[act.json]: {
|
|
312
|
+
patch: [{ op: writeOp, path: target, value: `$event.${JSON_FIELD}` }],
|
|
313
|
+
},
|
|
314
|
+
[act.add]: {
|
|
315
|
+
patch: [{
|
|
316
|
+
op: 'add',
|
|
317
|
+
path: { $concat: [dataPointer, '$payload.pointer', '/-'] },
|
|
318
|
+
value: '$payload.value',
|
|
319
|
+
}],
|
|
320
|
+
},
|
|
321
|
+
[act.remove]: {
|
|
322
|
+
patch: [{ op: 'remove', path: target }],
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file @jarenjs/app — applications as JSON documents: the compiled
|
|
4
|
+
* dispatch loop over @jarenjs/json engines and the @jarenjs/view
|
|
5
|
+
* renderer. See README.md and docs/APP-FORMAT.md.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { createApp } from './app.js';
|
|
9
|
+
export { compileActions, compileSubs } from './actions.js';
|
|
10
|
+
export { createFormView, createFormActions, formEventFields } from './forms.js';
|
|
11
|
+
export { createTaskEffect } from './tasks.js';
|
|
12
|
+
export { createFocusEffect } from './focus.js';
|
|
13
|
+
export { createTransactionLog } from './diagnostics.js';
|
|
14
|
+
export { createSplitterWidget } from './splitter.js';
|
|
15
|
+
export { createDocStore, encodeShare, decodeShare } from './docstore.js';
|
|
16
|
+
export { AppCompileError, AppRuntimeError, HostValueError, toError, APP_CODES } from './errors.js';
|
package/src/splitter.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file A reusable drag-splitter widget for a two-pane (or rail|editor|stage)
|
|
4
|
+
* grid. It drives a CSS ratio variable LIVE during a drag — no per-move
|
|
5
|
+
* dispatch, which would flood the transaction log and undo — and commits the
|
|
6
|
+
* ratio on pointer-UP only, plus keyboard resize as an ARIA separator. Every
|
|
7
|
+
* DOM call is guarded so it mounts inertly over a headless stub (there the
|
|
8
|
+
* live drag is browser-verified). The host binds it as a widget and
|
|
9
|
+
* parameterizes the grid/rail selectors, the CSS variable and the commit
|
|
10
|
+
* action, so studio, play and any future two-pane surface share one splitter.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} opts
|
|
15
|
+
* @param {string} opts.action - the app action dispatched with the committed ratio
|
|
16
|
+
* @param {string} [opts.grid='.jstudio'] - selector for the grid element (the host's closest ancestor)
|
|
17
|
+
* @param {string} [opts.rail] - selector for a fixed left rail inside the grid; the ratio
|
|
18
|
+
* spans from the rail's right edge (or the grid's left when absent) to the grid's right
|
|
19
|
+
* @param {string} [opts.cssVar='--js-ratio'] - the CSS custom property the grid reads for the split
|
|
20
|
+
* @param {number} [opts.min=0.1] - the smallest left-pane ratio (also Home)
|
|
21
|
+
* @param {number} [opts.max=0.9] - the largest left-pane ratio (also End)
|
|
22
|
+
* @param {number} [opts.step=0.05] - the arrow-key step
|
|
23
|
+
* @param {number} [opts.fineStep=0.01] - the Shift+arrow step
|
|
24
|
+
* @returns {{ mount: Function, update: Function, unmount: Function }}
|
|
25
|
+
*/
|
|
26
|
+
export function createSplitterWidget(opts) {
|
|
27
|
+
const action = opts.action;
|
|
28
|
+
const gridSel = opts.grid ?? '.jstudio';
|
|
29
|
+
const railSel = opts.rail ?? null;
|
|
30
|
+
const cssVar = opts.cssVar ?? '--js-ratio';
|
|
31
|
+
const min = opts.min ?? 0.1;
|
|
32
|
+
const max = opts.max ?? 0.9;
|
|
33
|
+
const step = opts.step ?? 0.05;
|
|
34
|
+
const fineStep = opts.fineStep ?? 0.01;
|
|
35
|
+
const clamp = (r) => Math.min(max, Math.max(min, r));
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
mount(host, props, emit) {
|
|
39
|
+
// pointermove/up ride the OWNER DOCUMENT for the span of a drag — the
|
|
40
|
+
// robust splitter pattern: the pointer leaves the thin handle at once,
|
|
41
|
+
// so listening on the handle alone (even with capture) drops the drag.
|
|
42
|
+
// Document listeners catch the move everywhere, torn off on pointer-up.
|
|
43
|
+
const doc = host.ownerDocument ?? null;
|
|
44
|
+
const gridOf = () => (typeof host.closest === 'function' ? host.closest(gridSel) : null);
|
|
45
|
+
const applyRatio = (r) => {
|
|
46
|
+
gridOf()?.style?.setProperty?.(cssVar, String(r));
|
|
47
|
+
host.setAttribute?.('aria-valuenow', String(Math.round(r * 100)));
|
|
48
|
+
};
|
|
49
|
+
// pointer x → the left content pane's share of the (post-rail) span
|
|
50
|
+
const ratioAt = (clientX) => {
|
|
51
|
+
const g = gridOf();
|
|
52
|
+
if (g === null) return handle.ratio;
|
|
53
|
+
const rail = railSel !== null && typeof g.querySelector === 'function' ? g.querySelector(railSel) : null;
|
|
54
|
+
const box = g.getBoundingClientRect();
|
|
55
|
+
const left = rail !== null ? rail.getBoundingClientRect().right : box.left;
|
|
56
|
+
if (!(box.right > left)) return handle.ratio;
|
|
57
|
+
return clamp((clientX - left) / (box.right - left));
|
|
58
|
+
};
|
|
59
|
+
const onMove = (e) => {
|
|
60
|
+
if (!handle.dragging) return;
|
|
61
|
+
e.preventDefault?.();
|
|
62
|
+
handle.ratio = ratioAt(e.clientX);
|
|
63
|
+
applyRatio(handle.ratio); // live only — the commit is on pointer-up
|
|
64
|
+
};
|
|
65
|
+
const onUp = () => {
|
|
66
|
+
if (!handle.dragging) return;
|
|
67
|
+
handle.dragging = false;
|
|
68
|
+
doc?.removeEventListener?.('pointermove', onMove);
|
|
69
|
+
doc?.removeEventListener?.('pointerup', onUp);
|
|
70
|
+
emit({ action, with: handle.ratio });
|
|
71
|
+
};
|
|
72
|
+
const onDown = (e) => {
|
|
73
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
74
|
+
handle.dragging = true;
|
|
75
|
+
e.preventDefault?.();
|
|
76
|
+
doc?.addEventListener?.('pointermove', onMove);
|
|
77
|
+
doc?.addEventListener?.('pointerup', onUp);
|
|
78
|
+
};
|
|
79
|
+
const onKey = (e) => {
|
|
80
|
+
const s = e.shiftKey ? fineStep : step;
|
|
81
|
+
let next = null;
|
|
82
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = clamp(handle.ratio - s);
|
|
83
|
+
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = clamp(handle.ratio + s);
|
|
84
|
+
else if (e.key === 'Home') next = min;
|
|
85
|
+
else if (e.key === 'End') next = max;
|
|
86
|
+
if (next === null) return;
|
|
87
|
+
e.preventDefault?.();
|
|
88
|
+
handle.ratio = next;
|
|
89
|
+
applyRatio(next);
|
|
90
|
+
emit({ action, with: next });
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handle = { host, doc, ratio: clamp(Number(props?.ratio ?? 0.5)), dragging: false, applyRatio, onMove, onUp };
|
|
94
|
+
applyRatio(handle.ratio);
|
|
95
|
+
host.addEventListener?.('pointerdown', onDown);
|
|
96
|
+
host.addEventListener?.('keydown', onKey);
|
|
97
|
+
handle.hostListeners = [['pointerdown', onDown], ['keydown', onKey]];
|
|
98
|
+
return handle;
|
|
99
|
+
},
|
|
100
|
+
update(handle, props) {
|
|
101
|
+
const r = clamp(Number(props?.ratio ?? 0.5));
|
|
102
|
+
if (r !== handle.ratio && !handle.dragging) {
|
|
103
|
+
handle.ratio = r;
|
|
104
|
+
handle.applyRatio(r);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
unmount(handle) {
|
|
108
|
+
for (const [type, fn] of handle.hostListeners) handle.host.removeEventListener?.(type, fn);
|
|
109
|
+
handle.doc?.removeEventListener?.('pointermove', handle.onMove);
|
|
110
|
+
handle.doc?.removeEventListener?.('pointerup', handle.onUp);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|