@loadbare/app 0.13.0 → 0.14.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.
Files changed (42) hide show
  1. package/dist/build/assemble.d.ts.map +1 -1
  2. package/dist/build/assemble.js +31 -1
  3. package/dist/build/assemble.js.map +1 -1
  4. package/dist/core/lb-constants.d.ts +11 -1
  5. package/dist/core/lb-constants.d.ts.map +1 -1
  6. package/dist/core/lb-constants.js +34 -0
  7. package/dist/core/lb-constants.js.map +1 -1
  8. package/dist/core/lb-types.d.ts +27 -0
  9. package/dist/core/lb-types.d.ts.map +1 -1
  10. package/dist/core/lb-types.js.map +1 -1
  11. package/dist/hub/lb-apply.d.ts +20 -3
  12. package/dist/hub/lb-apply.d.ts.map +1 -1
  13. package/dist/hub/lb-apply.js +64 -8
  14. package/dist/hub/lb-apply.js.map +1 -1
  15. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  16. package/dist/hub/lb-hub.browser.js +341 -21
  17. package/dist/hub/lb-hub.browser.js.map +1 -1
  18. package/dist/server/lb-express.d.ts +13 -3
  19. package/dist/server/lb-express.d.ts.map +1 -1
  20. package/dist/server/lb-express.js +30 -12
  21. package/dist/server/lb-express.js.map +1 -1
  22. package/dist/server/lb-server.d.ts +31 -1
  23. package/dist/server/lb-server.d.ts.map +1 -1
  24. package/dist/server/lb-server.js +112 -2
  25. package/dist/server/lb-server.js.map +1 -1
  26. package/docs/TECHREF-1.0.md +294 -21
  27. package/docs/comparison.md +6 -4
  28. package/docs/possible-ideas.md +146 -26
  29. package/docs/reference/custom-elements.md +97 -1
  30. package/docs/reference/data-binding.md +303 -4
  31. package/docs/reference/page-files.md +76 -3
  32. package/docs/reference/server.md +20 -0
  33. package/docs/reference/widgets.md +33 -10
  34. package/docs/roadmap.md +9 -2
  35. package/package.json +1 -1
  36. package/skills/loadbare-app/SKILL.md +50 -7
  37. package/skills/loadbare-app/references/TECHREF-1.0.md +294 -21
  38. package/skills/loadbare-app/references/custom-elements.md +97 -1
  39. package/skills/loadbare-app/references/data-binding.md +303 -4
  40. package/skills/loadbare-app/references/page-files.md +76 -3
  41. package/skills/loadbare-app/references/server.md +20 -0
  42. package/skills/loadbare-app/references/widgets.md +33 -10
@@ -31,10 +31,9 @@ handle.
31
31
 
32
32
  ### Form controls
33
33
 
34
- - **Checkboxes and radio buttons are not implemented.** A value does not
35
- land on one and the hub does not gather one. Landing needs a decision on
36
- what counts as checked, which waits on [Data types](#data-types), and a
37
- radio group is several elements answering to one column.
34
+ - **Radio buttons are not implemented.** A value does not land on one and
35
+ the hub does not gather one. A radio group is several elements answering
36
+ to one column.
38
37
 
39
38
  ### Custom element hooks
40
39
 
@@ -70,10 +69,6 @@ But the allowed values may depend on other values in the row.
70
69
 
71
70
  ### The server API
72
71
 
73
- - **Decide the options-argument shape once.** Global hooks, transaction
74
- wrapping, error handling and a CSRF token all want the same trailing
75
- parameter on `createHub` and `hubRoutes`. Build none of them, but pick
76
- where they go.
77
72
  - **Land `staticRoutes`.** It closes the last place where an
78
73
  application hand-writes facts the builder owns.
79
74
 
@@ -457,7 +452,8 @@ A query that arrives with nowhere to land is reported to the console.
457
452
  | Any other element | Its text content |
458
453
 
459
454
  A control is an `<input>`, `<select>` or `<textarea>`, or a form-associated
460
- custom element with a `value` property that fires `change`. A
455
+ custom element with a `value` property that fires `change`. A checkbox is a
456
+ control too, by the rule below. A
461
457
  form-associated custom element declares `static formAssociated = true`.
462
458
 
463
459
  The hub stamps `lb-column-value` with the value on every element it sets.
@@ -468,9 +464,21 @@ renders `lb-column-value` itself.
468
464
  The hub gathers from the same controls it lands on, so a value read back is
469
465
  the one that landed.
470
466
 
471
- An `<input>` of type `checkbox`, `radio`, or `file` receives nothing, not
472
- even `lb-column-value`, and the hub reports it to the console. The hub does
473
- not gather one either.
467
+ A checkbox's state is `checked`, not `value`: HTML's quirk, and so the
468
+ hub's, so that every other part of the vocabulary treats it as any control.
469
+ The hub checks it by the rule `lb-show` is on by: `null` and `false` are
470
+ unchecked, and every other value is checked, with no string read. It
471
+ gathers `"true"` or `"false"`, so an unchecked box still says so, where a
472
+ native form submission leaves it out. The checkbox's own `value`
473
+ attribute means nothing to the hub.
474
+
475
+ ```html
476
+ <input type="checkbox" lb-column="active" lb-request="lb-row-update" />
477
+ ```
478
+
479
+ An `<input>` of type `radio` or `file` receives nothing, not even
480
+ `lb-column-value`, and the hub reports it to the console. The hub does not
481
+ gather one either.
474
482
 
475
483
  A `<select>` with no option for the value shows no selection.
476
484
 
@@ -782,9 +790,13 @@ Every request has one shape, on the event and on the wire:
782
790
  { name: "lb-row-update", query: "roster", key: "42", values: { name: "Ann" } }
783
791
  { name: "lb-row-delete", query: "roster", key: "42" }
784
792
  { name: "mailRoster", query: "roster", key: "42" }
793
+ { name: "lb-row-update", query: "roster", keys: ["42", "43"], values: { role: "lead" } }
794
+ { name: "lb-row-insert", query: "invoice", values: { memo: "May" }, rows: { lines: [{ key: "1", values: { qty: "3" } }] } }
785
795
  ```
786
796
 
787
- The hub sends `query`, `key` and `values` as present.
797
+ The hub sends `query`, `key` and `values` as present, and `keys` in place of
798
+ `key` for a request acting on the rows selected — see
799
+ [Selecting rows](#selecting-rows).
788
800
 
789
801
  #### Committing
790
802
 
@@ -852,7 +864,8 @@ controls belong to a form that cannot wrap a `<tr>`:
852
864
 
853
865
  The hub gathers from controls only. It skips a control whose nearest
854
866
  ancestor `lb-query` is a descendant of the live row or form, since that
855
- control belongs to a nested query. An element carrying `lb-query` and
867
+ control belongs to a nested query, except that a form gathers the rows of a
868
+ query nested in its own — see [A form over a set of rows](#a-form-over-a-set-of-rows). An element carrying `lb-query` and
856
869
  `lb-column` both, such as a picker, belongs to the row around it and is
857
870
  gathered.
858
871
 
@@ -866,6 +879,61 @@ ancestor `lb-query` of the element carrying `lb-request`, and takes `key`
866
879
  from that element's nearest ancestor row when that row is of the same
867
880
  query.
868
881
 
882
+ #### A form over a set of rows
883
+
884
+ A form gathers the live rows of a query nested directly in its own query,
885
+ besides its own controls. Each goes as its key and the values of the
886
+ controls in it, under the nested query's name:
887
+
888
+ ```
889
+ { name: "lb-row-insert", query: "paychecks", values: { pay_date: "2026-10-02" },
890
+ rows: { deduction_types: [{ key: "401k", values: { amount: "50" } },
891
+ { key: "dental", values: { amount: "" } }] } }
892
+ ```
893
+
894
+ The form's own query is the one it carries, or else the nearest around it.
895
+ A nested query is gathered when its nearest ancestor `lb-query` is the
896
+ form's own, so one level only: a query nested deeper shows the same rows in
897
+ every row around it, and its keys would repeat. Every live row of it goes,
898
+ whatever its values, and only controls inside a live row of it.
899
+
900
+ An insert, an update and a declared request carry `rows`; a delete does
901
+ not. A form whose only controls are in nested rows sends for its own query,
902
+ keyed by the row landed on it or the row it is pointed at, with empty
903
+ `values`. A live row gathers nothing nested.
904
+
905
+ The handler for the form's query receives `rows` beside `values`, so the
906
+ master and its details are written together. The server checks that
907
+ `rows` holds string keys and string values by query, and ignores a request
908
+ whose `rows` does not.
909
+
910
+ A nested query answers once for the page, as anywhere — see
911
+ [Master-detail](#master-detail). The form enters a new master with its
912
+ details, or edits the master the page shows by a query parm, whose details
913
+ the nested query reads from the same parm. A pointed form fills its own
914
+ controls from the row it points at, and its nested rows stay the nested
915
+ query's answer.
916
+
917
+ #### A row not yet entered
918
+
919
+ A row whose key is the empty string is a new one, not yet entered. It
920
+ arises two ways: a `row` query answers with an empty key, as one reading
921
+ the master's key from an empty query parm does, or `--lb-new` points a form
922
+ (see [A form pointed at a row](#a-form-pointed-at-a-row)). Either way the
923
+ element carries `lb-key-value=""`.
924
+
925
+ The hub sends no `key` for it, whether the key came from where the element
926
+ sits or from a request a custom element dispatched. An `lb-row-insert` and
927
+ a declared request are sent without one. An `lb-row-update` and an
928
+ `lb-row-delete` are not sent, because nothing is on record to change, and
929
+ nothing is reported: it is the row's state, not a mistake. So a control
930
+ saving its own column on `change` saves on a row on record, and on a new
931
+ row only holds what a submit will gather. No ancestor sees a request not
932
+ sent, and it gets no `lb-request-done`.
933
+
934
+ An update or a delete with no row around it at all has no key either, and
935
+ is reported to the console, as any request missing what it needs is.
936
+
869
937
  A field the request already carries is its issuer's and is kept. A custom
870
938
  element that dispatches its own request with `values` is not gathered over.
871
939
 
@@ -877,7 +945,8 @@ element that dispatches its own request with `values` is not gathered over.
877
945
  | `lb-row-update` | `query`, `key`, `values` | `rowUpdate` |
878
946
  | `lb-row-delete` | `query`, `key` | `rowDelete` |
879
947
 
880
- The hub issues one of these only when it has what the table lists. An
948
+ `keys` stands in for `key`. The hub issues one of these only when it has
949
+ what the table lists. An
881
950
  `lb-row-insert` or `lb-row-update` that gathers nothing is not issued. A
882
951
  request that is not issued is reported to the console.
883
952
 
@@ -892,6 +961,125 @@ For a query the hub serves, the hub answers the request itself, with no
892
961
  round trip. The hub serves `lb-url`, and answers `lb-row-update` for it —
893
962
  see [Query parms](#query-parms).
894
963
 
964
+ #### A form pointed at a row
965
+
966
+ A set of rows the user edits or adds to has one form on the page, naming
967
+ the same query, outside every row. A button points it with HTML's
968
+ `commandfor`, naming the form's `id`, and `command`:
969
+
970
+ | Command | On a button | The hub |
971
+ | -------------- | --------------------------------- | ----------------------------------------- |
972
+ | `--lb-edit` | In a live row of the form's query | Fills the form from that row, keyed by it |
973
+ | `--lb-new` | Anywhere | Starts the form empty, for an insert |
974
+ | `--lb-release` | Anywhere | Lets the form go without saving |
975
+
976
+ HTML requires a custom command to begin with `--`, and fires it as a
977
+ `command` event on the form, which the hub hears.
978
+
979
+ ```html
980
+ <tbody lb-query="paychecks">
981
+ <template><tr>
982
+ <td lb-column="pay_date"></td>
983
+ <td><button type="button" commandfor="paycheck" command="--lb-edit">Edit</button></td>
984
+ </tr></template>
985
+ </tbody>
986
+ <button type="button" commandfor="paycheck" command="--lb-new">New</button>
987
+
988
+ <dialog>
989
+ <form id="paycheck" lb-query="paychecks" lb-request="lb-row-update">
990
+ <input lb-column="pay_date" type="date" />
991
+ <button type="submit">Save</button>
992
+ <button type="submit" lb-request="lb-row-insert">Save new</button>
993
+ <button type="button" commandfor="paycheck" command="--lb-release">Cancel</button>
994
+ </form>
995
+ </dialog>
996
+ ```
997
+
998
+ Which row a form edits is interaction state, not a view, so it is not a
999
+ query parm. The user edits only a row they can see, so Edit fills the form
1000
+ from the row as the hub holds it, every column the query answered, with no
1001
+ round trip. A row that must be fetched to be edited is another page, with
1002
+ that row as its master.
1003
+
1004
+ Pointing a form first empties it: every column it names lands as null, and
1005
+ the form resets, so each control returns to its default. Edit then lands
1006
+ the row on the form as on any element a `row` lands on, stamps the form
1007
+ `lb-key-value` with the row's key, and stamps the row `lb-row-editing`.
1008
+ Gathering then takes the key from the form. New stamps an empty key, making
1009
+ the form [a row not yet entered](#a-row-not-yet-entered). Both focus the
1010
+ form's first control.
1011
+
1012
+ | The form's `lb-key-value` | The form is |
1013
+ | ------------------------- | ------------------ |
1014
+ | A key | Editing that row |
1015
+ | Empty | Entering a new row |
1016
+ | Absent | Not in use |
1017
+
1018
+ A stylesheet shows the form's buttons by it, such as Save while it has a
1019
+ key and Save new while the key is empty.
1020
+
1021
+ A request from the form that succeeds lets it go, as `--lb-release` does:
1022
+ the form empties, and both stamps go. One that fails leaves the form
1023
+ pointed, as the user had it.
1024
+
1025
+ When the form is inside a `<dialog>`, Edit and New call `showModal()` on it,
1026
+ and letting go closes it. The dialog closing by any of HTML's means,
1027
+ Escape, `command="close"` or `method="dialog"`, lets the form go. A form in
1028
+ no dialog opens nothing; it is always showing, or a stylesheet shows it
1029
+ while it carries `lb-key-value`.
1030
+
1031
+ The form names a `rows` query and holds no row template, so that query's
1032
+ answers land nothing on it, and what the user typed survives a refresh.
1033
+
1034
+ A command the hub cannot carry out is reported to the console: Edit from
1035
+ outside a live row of the form's query, Edit for a key the hub holds no row
1036
+ for, and any command aimed at an element that is not a form carrying
1037
+ `lb-query`.
1038
+
1039
+ #### Selecting rows
1040
+
1041
+ A checkbox carrying `lb-select` in a row template selects the live row it
1042
+ sits in, and the hub stamps the row `lb-row-selected` while it is ticked.
1043
+ It is not a column: it carries no `lb-column` or `lb-request`, lands
1044
+ nothing, and is not gathered.
1045
+
1046
+ A request for the query that names no one row, neither from inside one of
1047
+ its rows nor from a form pointed at one, acts on the rows selected. When
1048
+ any `lb-select` checkbox of that query is showing, the hub adds the keys of
1049
+ those ticked as `keys`:
1050
+
1051
+ ```
1052
+ { name: "lb-row-update", query: "transactions", keys: ["4", "9"], values: { acct: "12" } }
1053
+ ```
1054
+
1055
+ - `lb-row-update` and `lb-row-delete` are sent with `keys`, and with none
1056
+ ticked are not sent, silently, as for [a row not yet
1057
+ entered](#a-row-not-yet-entered).
1058
+ - A declared request is sent with `keys`, empty when none is ticked, and the
1059
+ handler decides.
1060
+ - `lb-row-insert` adds a row, and the selection is not its.
1061
+ - With no `lb-select` checkbox of the query showing, the request is an
1062
+ ordinary one, and carries no `keys`.
1063
+
1064
+ The server runs the page's `rowUpdate` or `rowDelete` once for each key,
1065
+ with that key, as for each row alone, so a handler written for one row
1066
+ serves several. It lays the patches the runs return together, and runs the
1067
+ refresh set once. A run returning `url()` is reported and ignored, since
1068
+ several rows cannot each choose one view. A declared request receives
1069
+ `keys` as sent.
1070
+
1071
+ After the request succeeds, the hub clears every `lb-select` checkbox of the
1072
+ query and the stamps with them. A failed request leaves the selection.
1073
+
1074
+ Only a checkbox that is showing counts: one parked by `lb-show` is in
1075
+ template content, out of reach. Parking one also clears its tick, so a
1076
+ selection never waits out of sight to return stale.
1077
+
1078
+ Which rows are selected is interaction state, as which row a form edits is.
1079
+ A selecting mode the page keeps in the URL reaches the rows as a column the
1080
+ query computes from the parm, which `lb-show` reads, since `lb-show` reads
1081
+ from the row it sits in.
1082
+
895
1083
  #### The request event
896
1084
 
897
1085
  The hub dispatches every request as the bubbling `lb-request` event before
@@ -935,6 +1123,46 @@ failed one.
935
1123
  The hub ignores a commit on an element carrying `lb-request-pending`, so a
936
1124
  pending element is disabled in fact and a stylesheet only has to show it.
937
1125
 
1126
+ #### Refusals
1127
+
1128
+ A handler refuses a request by throwing `refusal()`, exported by
1129
+ `@loadbare/app/server`, with words written for the user: a message for the
1130
+ request as a whole, a message per column, or both.
1131
+
1132
+ ```ts
1133
+ throw refusal("That series already has paychecks entered.");
1134
+ throw refusal({ amount: "Must be more than zero" });
1135
+ throw refusal("Two fields need attention.", { amount: "…", pay_date: "…" });
1136
+ ```
1137
+
1138
+ It is thrown rather than returned, so it may come from anything the handler
1139
+ calls, and rolls back whatever the application has open as any throw does.
1140
+ `hubRoutes` answers it with status 422 and `{ refusal: { message?, columns? } }`.
1141
+ Anything else thrown is a failure, answered 500 with no body, so no stack
1142
+ or database message reaches the browser.
1143
+
1144
+ The hub shows a refusal where it belongs:
1145
+
1146
+ - It stamps the element that committed `lb-request-refused`, whose value is
1147
+ the whole-request message, empty when there is none. A stylesheet shows
1148
+ it with `attr()`.
1149
+ - For each column, it calls `setCustomValidity(message)` on the control the
1150
+ request gathered that column from, and `reportValidity()` on the first,
1151
+ so the browser shows the message there and moves focus to it. A column
1152
+ the request gathered no such control for is reported to the console.
1153
+ - `lb-request-done` carries the refusal as `refusal`, beside `error`.
1154
+
1155
+ A refusal is not a failure: `lb-request-error` is not stamped and nothing is
1156
+ reported. It stands until the user edits any control the request gathered,
1157
+ a form holding them resets, or the element that committed sends again;
1158
+ then every message it put up is withdrawn at once. While a control carries
1159
+ one, the browser holds its form's submit, as for any invalid control.
1160
+
1161
+ A form-associated custom element takes a column's message when it defines
1162
+ `setCustomValidity` and `reportValidity`; one wrapping a native control
1163
+ passes both to it. A request for several rows takes the whole-request
1164
+ message only.
1165
+
938
1166
  #### The round trip
939
1167
 
940
1168
  A request the hub sends has ten seconds to come back. Past that the hub
@@ -943,7 +1171,8 @@ its own. An application cannot change the deadline.
943
1171
 
944
1172
  A round trip fails on a server error, on a network failure, or on that
945
1173
  deadline, and all three stamp `lb-request-error` on the element that issued
946
- the request — see [Request state](#request-state).
1174
+ the request — see [Request state](#request-state). A refusal is not a
1175
+ failure — see [Refusals](#refusals).
947
1176
 
948
1177
  After a successful `lb-row-insert` gathered from a form, the hub resets the
949
1178
  form, after it lands the response. A failed insert resets nothing, so the
@@ -1211,8 +1440,8 @@ to build on any of these:
1211
1440
 
1212
1441
  - An `lb-` attribute that is not a developer attribute: `lb-query`,
1213
1442
  `lb-column`, `lb-show`, `lb-group`, `lb-count`, `lb-sum`, `lb-avg`,
1214
- `lb-min`, `lb-max`, `lb-request`, `lb-url-link`, `lb-url-push`,
1215
- `lb-url-unknown`. Every other `lb-` attribute is a stamp.
1443
+ `lb-min`, `lb-max`, `lb-request`, `lb-select`, `lb-url-link`,
1444
+ `lb-url-push`, `lb-url-unknown`. Every other `lb-` attribute is a stamp.
1216
1445
  `lb-exp-slot` and `lb-exp-template` are consumed by expansion and never
1217
1446
  reach the check.
1218
1447
  - `lb-request` naming a value that begins with `lb-` and is not one of the
@@ -1224,6 +1453,11 @@ to build on any of these:
1224
1453
  - `lb-show` on a `<template>`, on a row template's root, or with no
1225
1454
  `lb-query` around it.
1226
1455
  - `lb-show="!"` or `lb-show="!!column"`, which reverse no column.
1456
+ - `lb-select` on anything but an `<input type="checkbox">`, outside a row
1457
+ template, or beside `lb-column` or `lb-request`.
1458
+ - A `command` beginning `--lb-` that is not one of the three
1459
+ [commands](#a-form-pointed-at-a-row), and one of them with no
1460
+ `commandfor`.
1227
1461
  - `lb-group` on anything but a `<template>`, or on one holding no
1228
1462
  `<template>` for its contents.
1229
1463
  - An aggregate on a `<template>`, and any but `lb-count` naming no column.
@@ -1403,6 +1637,16 @@ Explaining Express is beyond the scope of this technical reference. The
1403
1637
  only real requirement is that the catch-all for app.html is at the end,
1404
1638
  so it does not catch any other files.
1405
1639
 
1640
+ `hubRoutes` mounts `express.json()` on its own routes, and takes that
1641
+ function's options under `json` in an optional third argument. The default
1642
+ limit of 100 KB refuses an oversized body with a 413 before any handler
1643
+ runs, and the server log names the option. A page that sends a file's text
1644
+ as a value needs more:
1645
+
1646
+ ```ts
1647
+ app.use(hubRoutes(hub, contextFor, { json: { limit: "5mb" } }));
1648
+ ```
1649
+
1406
1650
  `hubRoutes` answers `POST /lb/<stub>`, with the query string the browser is
1407
1651
  showing after it, verbatim. It hands `contextFor` that query string's parms
1408
1652
  as a second argument, and `contextFor` puts on the context whatever a query
@@ -1569,11 +1813,16 @@ interface HubRequest {
1569
1813
  name: string;
1570
1814
  query?: string;
1571
1815
  key?: string;
1816
+ keys?: string[];
1572
1817
  values?: Record<string, string>;
1818
+ rows?: Record<string, { key: string; values: Record<string, string> }[]>;
1573
1819
  }
1574
1820
  ```
1575
1821
 
1576
- Every response is an array of response items:
1822
+ A refused request is answered with status 422 and
1823
+ `{ refusal: { message?, columns? } }`, and any other failure with 500 and
1824
+ no body — see [Refusals](#refusals). Every other response is an array of
1825
+ response items:
1577
1826
 
1578
1827
  ```ts
1579
1828
  interface ResponseItem {
@@ -1791,6 +2040,7 @@ ownership of its behavior are stated separately, because they differ.
1791
2040
  | Namespace | Applies to | Loadbare owns | Defined in |
1792
2041
  | --------- | ----------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------- |
1793
2042
  | `lb-*` | Attributes, their values, queries, columns of `lb-` queries, requests, custom elements, events | The names and their behavior | [The lb-* namespace](#the-lb--namespace) |
2043
+ | `--lb-*` | Values of HTML's `command` attribute | The names and their behavior | [Reserved commands](#reserved-commands) |
1794
2044
  | `exp-*` | Attributes on a custom element | The behavior; element file authors pick the names | [Build time parameters](#build-time-parameters) |
1795
2045
  | `lb*` | Methods on custom elements | The names and their behavior | [Row hooks](#row-hooks) |
1796
2046
 
@@ -1807,6 +2057,7 @@ it may define tomorrow.
1807
2057
  | `lb-column` | Developer | Any element | [Landing](#landing) |
1808
2058
  | `lb-show` | Developer | Any element but a `<template>` | [Displaying by condition](#displaying-by-condition) |
1809
2059
  | `lb-request` | Developer | Any element | [Requests](#requests) |
2060
+ | `lb-select` | Developer | A checkbox in a row template | [Selecting rows](#selecting-rows) |
1810
2061
  | `lb-url-link` | Developer | An `<a>` | [Links](#links) |
1811
2062
  | `lb-url-push` | Developer | An element with `lb-request` | [Query parms](#query-parms) |
1812
2063
  | `lb-url-unknown` | Developer | A `<dialog>` inside `<lb-hub>` | [An unknown page](#an-unknown-page) |
@@ -1815,7 +2066,9 @@ it may define tomorrow.
1815
2066
  | `lb-exp-slot` | Developer | One element in an element file | [Slots and templates](#slots-and-templates) |
1816
2067
  | `lb-exp-template` | Developer | An element file, and a `<template>` | [Slots and templates](#slots-and-templates) |
1817
2068
  | `lb-column-value` | Hub | Every element set from a column | [How a column lands](#how-a-column-lands) |
1818
- | `lb-key-value` | Hub | A live row, and an element a `row` lands on | [Rows](#rows) |
2069
+ | `lb-key-value` | Hub | A live row, an element a `row` lands on, and a pointed form | [Rows](#rows), [A form pointed at a row](#a-form-pointed-at-a-row) |
2070
+ | `lb-row-editing` | Hub | The live row a form is pointed at | [A form pointed at a row](#a-form-pointed-at-a-row) |
2071
+ | `lb-row-selected` | Hub | A live row its `lb-select` checkbox ticks | [Selecting rows](#selecting-rows) |
1819
2072
  | `lb-row-live` | Hub | A live row | [Rows](#rows) |
1820
2073
  | `lb-query-row-count` | Hub | An element with a row template | [Rows](#rows) |
1821
2074
  | `lb-group-live`, `lb-group-column`, `lb-group-value` | Hub | Each top-level element of a group | [Groups](#groups) |
@@ -1823,6 +2076,7 @@ it may define tomorrow.
1823
2076
  | `lb-row-leaving`, `lb-group-leaving` | Hub | A row or a group on its way out | [What happened to a row](#what-happened-to-a-row) |
1824
2077
  | `lb-request-pending` | Hub | The element that issued a request | [Request state](#request-state) |
1825
2078
  | `lb-request-error` | Hub | The element that issued a request | [Request state](#request-state) |
2079
+ | `lb-request-refused` | Hub | The element whose request was refused | [Refusals](#refusals) |
1826
2080
  | `lb-show` on a `<template>` | Hub and builder | Where an absent element stands | [Displaying by condition](#displaying-by-condition) |
1827
2081
  | `lb-page` | Builder | A page's `<template>` | [Page HTML](#page-html) |
1828
2082
  | `lb-page-title` | Builder | A page's `<template>` | [Page HTML](#page-html) |
@@ -1850,6 +2104,17 @@ The builder refuses any other `lb-` attribute in markup — see
1850
2104
  | `lb-row-update` | `rowUpdate` | [The request names](#the-request-names) |
1851
2105
  | `lb-row-delete` | `rowDelete` | [The request names](#the-request-names) |
1852
2106
 
2107
+ ### Reserved commands
2108
+
2109
+ Loadbare/app owns every value of HTML's `command` attribute beginning with
2110
+ `--lb-`.
2111
+
2112
+ | Command | Defined in |
2113
+ | -------------- | --------------------------------------------------- |
2114
+ | `--lb-edit` | [A form pointed at a row](#a-form-pointed-at-a-row) |
2115
+ | `--lb-new` | [A form pointed at a row](#a-form-pointed-at-a-row) |
2116
+ | `--lb-release` | [A form pointed at a row](#a-form-pointed-at-a-row) |
2117
+
1853
2118
  ### Reserved tags
1854
2119
 
1855
2120
  Loadbare/app owns every tag in this table. An application writes them and
@@ -1906,9 +2171,17 @@ imports them rather than writing a string.
1906
2171
  | `REQUESTED_CREATED`, `REQUESTED_CHANGED`, `REQUESTED_MOVED` | `created`, `changed`, `moved` |
1907
2172
  | `ATTR_ROW_LEAVING` | `lb-row-leaving` |
1908
2173
  | `ATTR_GROUP_LEAVING` | `lb-group-leaving` |
2174
+ | `COMMAND_EDIT`, `COMMAND_NEW`, `COMMAND_RELEASE` | `--lb-edit`, `--lb-new`, `--lb-release` |
2175
+ | `LB_COMMANDS` | All three commands |
2176
+ | `LB_COMMAND_PREFIX` | `--lb-` |
2177
+ | `ATTR_ROW_EDITING` | `lb-row-editing` |
2178
+ | `ATTR_SELECT` | `lb-select` |
2179
+ | `ATTR_ROW_SELECTED` | `lb-row-selected` |
1909
2180
  | `ATTR_REQUEST` | `lb-request` |
1910
2181
  | `ATTR_REQUEST_PENDING` | `lb-request-pending` |
1911
2182
  | `ATTR_REQUEST_ERROR` | `lb-request-error` |
2183
+ | `ATTR_REQUEST_REFUSED` | `lb-request-refused` |
2184
+ | `LB_REFUSAL_STATUS` | `422` |
1912
2185
  | `ATTR_URL_LINK` | `lb-url-link` |
1913
2186
  | `ATTR_URL_PUSH` | `lb-url-push` |
1914
2187
  | `ATTR_URL_UNKNOWN` | `lb-url-unknown` |
@@ -1916,7 +2189,7 @@ imports them rather than writing a string.
1916
2189
  | `ATTR_EXP_TEMPLATE` | `lb-exp-template` |
1917
2190
  | `ATTR_PAGE` | `lb-page` |
1918
2191
  | `ATTR_PAGE_TITLE` | `lb-page-title` |
1919
- | `DEVELOPER_ATTRIBUTES` | The thirteen attributes a developer writes |
2192
+ | `DEVELOPER_ATTRIBUTES` | The fourteen attributes a developer writes |
1920
2193
  | `LB_EVENT_NAME` | `lb-request` |
1921
2194
  | `LB_DONE_EVENT_NAME` | `lb-request-done` |
1922
2195
  | `SHOW_NOT` | `!`, written before a column in `lb-show` |
@@ -412,9 +412,104 @@ including through the HTML `form` attribute, so a form gathers it on submit.
412
412
  Write `formResetCallback` to restore the default value: the hub resets a form
413
413
  after a successful insert.
414
414
 
415
+ A control takes the message of a refusal naming its column when it has
416
+ `setCustomValidity` and `reportValidity`, as a native control does; see
417
+ [Refusals](./data-binding.md#refusals). A control wrapping a native one
418
+ passes both to it, so the browser shows the message there and focus lands
419
+ there.
420
+
415
421
  The `<lb-input>`, `<lb-select>`, `<lb-options>` and `<lb-picker>` widgets
416
422
  are controls; see [The Basic Widget Library](./widgets.md).
417
423
 
424
+ ### Example: a file's text as a value
425
+
426
+ The hub takes no file input as a control. A custom element wraps one and is
427
+ the control in its place. This one reads the chosen file as text, which
428
+ suits a CSV or a bank statement, and sends the file's name under a second
429
+ column:
430
+
431
+ ```html
432
+ <!-- src/file-text.html -->
433
+ <label>{{label}} <input type="file" accept="{{accept}}" /></label>
434
+ <input type="hidden" lb-column="{{name-column}}" />
435
+ ```
436
+
437
+ ```ts
438
+ // src/file-text.browser.ts
439
+ class FileText extends HTMLElement {
440
+ static formAssociated = true;
441
+ #internals = this.attachInternals();
442
+ #value = "";
443
+
444
+ constructor() {
445
+ super();
446
+ this.addEventListener("change", (e) => {
447
+ const input = e.target as HTMLInputElement;
448
+ if (e.target === this || input.type !== "file") return;
449
+ e.stopPropagation();
450
+ void this.read(input);
451
+ });
452
+ }
453
+
454
+ get value(): string {
455
+ return this.#value;
456
+ }
457
+
458
+ set value(value: string) {
459
+ this.#value = value ?? "";
460
+ this.#internals.setFormValue(this.#value);
461
+ }
462
+
463
+ formResetCallback() {
464
+ this.value = "";
465
+ this.setName("");
466
+ }
467
+
468
+ async read(input: HTMLInputElement) {
469
+ const file = input.files?.[0] ?? null;
470
+ this.value = file ? await file.text() : "";
471
+ this.setName(file?.name ?? "");
472
+ this.dispatchEvent(new Event("change", { bubbles: true }));
473
+ }
474
+
475
+ setName(name: string) {
476
+ const hidden = this.querySelector<HTMLInputElement>('input[type="hidden"]');
477
+ if (hidden) hidden.value = name;
478
+ }
479
+ }
480
+
481
+ customElements.define("file-text", FileText);
482
+ ```
483
+
484
+ ```html
485
+ <!-- src/uploads.page.html -->
486
+ <form lb-query="uploads" lb-request="lb-row-insert">
487
+ <file-text
488
+ lb-column="filetext"
489
+ exp-name-column="filename"
490
+ exp-label="Statement file"
491
+ exp-accept=".csv,.ofx"
492
+ ></file-text>
493
+ <button type="submit">Upload</button>
494
+ </form>
495
+ ```
496
+
497
+ Reading a file is asynchronous, so the element stops the file input's own
498
+ `change` and fires its own once the text is in hand. A form submitted before
499
+ then gathers the empty value, and the handler refuses it as no file.
500
+
501
+ A control gathers one column, so the name rides in a hidden input that the
502
+ hub gathers as a control of its own. The form gathers both on submit. Its
503
+ reset after the insert clears the file input but not the hidden one, whose
504
+ `value` is its default, so `formResetCallback` clears the name itself.
505
+
506
+ The text travels in the request's JSON body, which `hubRoutes` limits to
507
+ 100 KB unless the server raises it; see [Options](./server.md#options).
508
+
509
+ The choices here are this application's: text rather than binary, the whole
510
+ file in the request, the name in a second column. A different application
511
+ writes its own element, under its own name, to make different ones.
512
+
418
513
  ### Receiving a value
419
514
 
420
515
  A custom element that is not a control keeps the content the builder placed
@@ -494,7 +589,8 @@ this.addEventListener(LB_DONE_EVENT_NAME, (e) => {
494
589
  ```
495
590
 
496
591
  `items` is every response item that landed because of the request, in
497
- order. An answer that moved the URL contributes its `lb-url` item, which
592
+ order. `refusal` is set beside `error` when the handler refused the
593
+ request, holding its `message` and `columns`. An answer that moved the URL contributes its `lb-url` item, which
498
594
  carries a key only the server knew, and the page load. `error` is set when
499
595
  the round trip failed, and then nothing landed. A new row's key is in the
500
596
  answer only when the handler returned it in a patch: a refresh sends all