@jirawatpyk/aura-react 5.5.0 → 5.6.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 CHANGED
@@ -186,13 +186,30 @@ DataTable with sort and page in the URL (4.17): `onStateChange={({ sort, page })
186
186
  - **Time zone**: `timeZone` on AuraProvider, DatePicker, DateRangePicker and Calendar decides "today" (the marker, `min`/`max="today"`, the first month shown); or pass `today` as an ISO date. `todayIn('Asia/Bangkok')` is exported from the root and `/server`.
187
187
  - **Toasts** queue past three instead of dropping: six in a row all show, in order, three at a time.
188
188
 
189
+ ## 5.6 — Chamber-OS addendum 4 (selectable rows, richer toasts)
190
+
191
+ - **DataTable** `isRowSelectable={(r) => r.status === 'Awaiting review'}`: a row it rejects shows no checkbox (the cell stays, so columns line up), and select-all, Space, the "N selected" count and `onSelectionChange` skip it. A rejected key passed in through `selected` is never shown as selected and is dropped from your state with one `onSelectionChange` call. `rowSelectDisabledLabel={(r) => …}` names the empty cell for screen readers. Server HTML has no checkbox for those rows.
192
+ - **Toasts**: `description` takes JSX (lines with their own links, read once in the live region); `actions` (up to two; two, or one under a description, go on their own row below the text); an action with `href` is a link through `linkComponent` (Cmd/Ctrl-click opens a tab and keeps the toast); `dismiss: false` keeps the toast open after its action. Escape closes the toast under focus.
193
+ - **Toaster** `position="top-center"` (or `bottom-center`, `top-right`, `bottom-right`) and `offset={64}` (or `--aura-toaster-offset`) to clear a top bar, plus the safe-area inset; below 640px toasts still span the width.
194
+ - **Alt+T** (`hotkey`, matched on the physical key so macOS Option+T works; `false` turns it off) focuses the newest toast's action, else its close button, and pauses its timer while focus stays inside (moving the mouse away doesn't restart it); the region's name and `aria-keyshortcuts` say the shortcut. It matches the key position, so it works on Thai and other layouts; in a text field on macOS, where Option+T types "†", it is left to the field.
195
+ - **Focus comes back**: when a toast holding focus closes (its action ran, Escape, the close button), focus returns to where it was before — the element Alt+T was pressed on, else the one focused when the toast appeared — instead of falling to `<body>`.
196
+ - **Touch**: toast actions get a 44px hit area on coarse pointers, as IconButton does (links in the text get a little padding; a 44px box would overlap the next line). Bottom toasters add the bottom safe-area inset too.
197
+
189
198
  ## 5.5 — DataTable is generic over its rows
190
199
 
191
200
  - The type of `rows` is the table's row type, and every `render`, `sortValue`, `getRowHref` and `onRowActivate` gets it — no annotations, and a typo inside a callback (`r.amuont`) or a callback written for another type is a compile error (column `key`s stay plain strings):
192
201
 
193
202
  ```tsx
194
- const columns: DataTableColumn<Order>[] = [{ key: 'amount', label: 'AMOUNT', align: 'end', render: (r) => baht(r.amount) }];
195
- <DataTable label="Orders" rows={orders} columns={columns} onRowActivate={setDetail} getRowHref={(r) => '/orders/' + r.id} />
203
+ const columns: DataTableColumn<Order>[] = [
204
+ { key: 'amount', label: 'AMOUNT', align: 'end', render: (r) => baht(r.amount) },
205
+ ];
206
+ <DataTable
207
+ label="Orders"
208
+ rows={orders}
209
+ columns={columns}
210
+ onRowActivate={setDetail}
211
+ getRowHref={(r) => '/orders/' + r.id}
212
+ />;
196
213
  ```
197
214
 
198
215
  - `DataTableColumn<Row>` types a column list declared on its own; `<DataTable<Order> …>` names the type explicitly. `rows={[]}` (nothing loaded yet) keeps the untyped default, and so do `any[]`, `Record<string, any>[]` and anything reading `typeof DataTable` (`ComponentProps`, `memo`, wrappers).
@@ -3079,10 +3079,35 @@ window.Aura = (() => {
3079
3079
  promote();
3080
3080
  emitToasts();
3081
3081
  };
3082
+ var hotkeyOrigin = /* @__PURE__ */ new WeakMap();
3083
+ function outsideToaster(el) {
3084
+ return el && el !== document.body && !el.closest(".aura-toaster") ? el : null;
3085
+ }
3082
3086
  function ToastItem(props) {
3083
3087
  const str = useStrings();
3088
+ const Link = useLinkComponent();
3089
+ const node = React19.useRef(null);
3090
+ const origin = React19.useRef(null);
3091
+ useIsoLayoutEffect(function() {
3092
+ origin.current = outsideToaster(document.activeElement);
3093
+ const el = node.current;
3094
+ return function() {
3095
+ if (!el || !el.contains(document.activeElement)) return;
3096
+ const back = [hotkeyOrigin.get(el), origin.current].filter(function(b) {
3097
+ return !!b && b.isConnected;
3098
+ })[0];
3099
+ hotkeyOrigin.delete(el);
3100
+ setTimeout(function() {
3101
+ if (back && back.isConnected && (!document.activeElement || document.activeElement === document.body))
3102
+ back.focus();
3103
+ }, 0);
3104
+ };
3105
+ }, []);
3084
3106
  const t = props.toast, timer = React19.useRef(null), left = React19.useRef(t.duration || 5e3), since = React19.useRef(0);
3085
- const hover = React19.useRef(false);
3107
+ const hover = React19.useRef(false), focused = React19.useRef(false);
3108
+ function syncFocus() {
3109
+ if (focused.current && !(node.current && node.current.contains(document.activeElement))) focused.current = false;
3110
+ }
3086
3111
  function start() {
3087
3112
  if (left.current === Infinity || timer.current) return;
3088
3113
  since.current = Date.now();
@@ -3091,11 +3116,10 @@ window.Aura = (() => {
3091
3116
  }, left.current);
3092
3117
  }
3093
3118
  function resume() {
3094
- hover.current = false;
3095
- start();
3119
+ syncFocus();
3120
+ if (!hover.current && !focused.current) start();
3096
3121
  }
3097
3122
  function pause() {
3098
- hover.current = true;
3099
3123
  if (timer.current) {
3100
3124
  clearTimeout(timer.current);
3101
3125
  timer.current = null;
@@ -3107,23 +3131,56 @@ window.Aura = (() => {
3107
3131
  if (timer.current) clearTimeout(timer.current);
3108
3132
  timer.current = null;
3109
3133
  left.current = t.duration || 5e3;
3110
- if (!hover.current) start();
3134
+ syncFocus();
3135
+ if (!hover.current && !focused.current) start();
3111
3136
  return function() {
3112
3137
  if (timer.current) clearTimeout(timer.current);
3113
3138
  };
3114
3139
  },
3115
3140
  [t.rev]
3116
3141
  );
3142
+ const rich = t.description != null && typeof t.description !== "boolean" && t.description !== "";
3143
+ const actions = (t.actions && t.actions.length ? t.actions : t.action ? [t.action] : []).slice(0, 2);
3144
+ function run(a) {
3145
+ if (a.onClick) a.onClick();
3146
+ if (a.dismiss !== false) toast.dismiss(t.id);
3147
+ }
3117
3148
  return /* @__PURE__ */ React19.createElement(
3118
3149
  "div",
3119
3150
  {
3120
- className: cx("aura-toast", "aura-toast--" + t.tone, t.loading && "is-loading"),
3151
+ ref: node,
3152
+ onKeyDown: function(e) {
3153
+ if (e.key === "Escape") {
3154
+ e.stopPropagation();
3155
+ toast.dismiss(t.id);
3156
+ }
3157
+ },
3158
+ className: cx(
3159
+ "aura-toast",
3160
+ "aura-toast--" + t.tone,
3161
+ t.loading && "is-loading",
3162
+ /* Two actions, or one beside a description, go on their own row under the text so it keeps its width. */
3163
+ (actions.length > 1 || actions.length && rich) && "has-action-row"
3164
+ ),
3121
3165
  role: t.tone === "danger" ? "alert" : "status",
3122
3166
  "aria-busy": t.loading || void 0,
3123
- onMouseEnter: pause,
3124
- onMouseLeave: resume,
3125
- onFocus: pause,
3126
- onBlur: resume
3167
+ onMouseEnter: function() {
3168
+ hover.current = true;
3169
+ pause();
3170
+ },
3171
+ onMouseLeave: function() {
3172
+ hover.current = false;
3173
+ resume();
3174
+ },
3175
+ onFocus: function() {
3176
+ focused.current = true;
3177
+ pause();
3178
+ },
3179
+ onBlur: function(e) {
3180
+ if (node.current && e.relatedTarget && node.current.contains(e.relatedTarget)) return;
3181
+ focused.current = false;
3182
+ resume();
3183
+ }
3127
3184
  },
3128
3185
  /* @__PURE__ */ React19.createElement(
3129
3186
  Icon,
@@ -3132,19 +3189,33 @@ window.Aura = (() => {
3132
3189
  className: cx("aura-toast__icon", t.loading && "aura-spin")
3133
3190
  }
3134
3191
  ),
3135
- /* @__PURE__ */ React19.createElement("div", { className: "aura-toast__body" }, /* @__PURE__ */ React19.createElement("p", { className: "aura-toast__title" }, t.title), t.description ? /* @__PURE__ */ React19.createElement("p", { className: "aura-toast__text" }, t.description) : null),
3136
- t.action ? /* @__PURE__ */ React19.createElement(
3137
- "button",
3138
- {
3139
- type: "button",
3140
- className: "aura-toast__action",
3141
- onClick: function() {
3142
- t.action.onClick && t.action.onClick();
3143
- toast.dismiss(t.id);
3144
- }
3145
- },
3146
- t.action.label
3147
- ) : null,
3192
+ /* @__PURE__ */ React19.createElement("div", { className: "aura-toast__body" }, /* @__PURE__ */ React19.createElement("p", { className: "aura-toast__title" }, t.title), rich ? /* @__PURE__ */ React19.createElement("div", { className: "aura-toast__text" }, t.description) : null),
3193
+ actions.length ? /* @__PURE__ */ React19.createElement("div", { className: "aura-toast__actions" }, actions.map(function(a, i) {
3194
+ return a.href ? /* @__PURE__ */ React19.createElement(
3195
+ Link,
3196
+ {
3197
+ key: i,
3198
+ href: a.href,
3199
+ className: "aura-toast__action",
3200
+ onClick: function(e) {
3201
+ if (plainClick(e)) run(a);
3202
+ else if (a.onClick) a.onClick();
3203
+ }
3204
+ },
3205
+ a.label
3206
+ ) : /* @__PURE__ */ React19.createElement(
3207
+ "button",
3208
+ {
3209
+ key: i,
3210
+ type: "button",
3211
+ className: "aura-toast__action",
3212
+ onClick: function() {
3213
+ run(a);
3214
+ }
3215
+ },
3216
+ a.label
3217
+ );
3218
+ })) : null,
3148
3219
  /* @__PURE__ */ React19.createElement(
3149
3220
  IconButton,
3150
3221
  {
@@ -3158,7 +3229,22 @@ window.Aura = (() => {
3158
3229
  )
3159
3230
  );
3160
3231
  }
3161
- function Toaster(props) {
3232
+ function hotkeyMatcher(spec) {
3233
+ const parts = spec.split("+").map(function(p) {
3234
+ return p.trim().toLowerCase();
3235
+ });
3236
+ const key = parts[parts.length - 1] || "";
3237
+ const code = /^[a-z]$/.test(key) ? "Key" + key.toUpperCase() : /^[0-9]$/.test(key) ? "Digit" + key : null;
3238
+ const want = function(m) {
3239
+ return parts.indexOf(m) >= 0 && parts.indexOf(m) < parts.length - 1;
3240
+ };
3241
+ return function(e) {
3242
+ if (e.altKey !== want("alt") || e.ctrlKey !== want("ctrl") || e.shiftKey !== want("shift") || e.metaKey !== (want("meta") || want("cmd")))
3243
+ return false;
3244
+ return code ? e.code === code : e.key.toLowerCase() === key;
3245
+ };
3246
+ }
3247
+ function Toaster(props = {}) {
3162
3248
  const t = useStrings();
3163
3249
  const mounted = useMounted();
3164
3250
  const s = React19.useState(toastState.list);
@@ -3171,15 +3257,49 @@ window.Aura = (() => {
3171
3257
  });
3172
3258
  };
3173
3259
  }, []);
3260
+ const region = React19.useRef(null);
3261
+ const hotkey = props.hotkey === void 0 ? "Alt+T" : props.hotkey;
3262
+ React19.useEffect(
3263
+ function() {
3264
+ if (!hotkey) return;
3265
+ const match = hotkeyMatcher(hotkey);
3266
+ function onKey(e) {
3267
+ if (!e.key || e.repeat || e.isComposing || !match(e) || !region.current) return;
3268
+ const el = e.target;
3269
+ const editable = !!el && (el.isContentEditable || /^(INPUT|TEXTAREA)$/.test(el.tagName || ""));
3270
+ const mac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || "");
3271
+ if (editable && mac && e.altKey && !e.ctrlKey && !e.metaKey && e.key.length === 1) return;
3272
+ const items2 = region.current.querySelectorAll(".aura-toast");
3273
+ const last = items2[items2.length - 1];
3274
+ if (!last) return;
3275
+ const target = last.querySelector(".aura-toast__action") || last.querySelector("button");
3276
+ if (!target) return;
3277
+ e.preventDefault();
3278
+ const from = outsideToaster(document.activeElement);
3279
+ if (from) hotkeyOrigin.set(last, from);
3280
+ target.focus();
3281
+ }
3282
+ document.addEventListener("keydown", onKey);
3283
+ return function() {
3284
+ document.removeEventListener("keydown", onKey);
3285
+ };
3286
+ },
3287
+ [hotkey]
3288
+ );
3174
3289
  if (!mounted) return null;
3290
+ const pos = props.position || "bottom";
3291
+ const offset = props.offset;
3175
3292
  return (0, import_react_dom5.createPortal)(
3176
3293
  /* @__PURE__ */ React19.createElement(
3177
3294
  "div",
3178
3295
  {
3179
- className: cx("aura-toaster", props && props.position === "top" && "is-top"),
3296
+ ref: region,
3297
+ className: cx("aura-toaster", pos.indexOf("top") === 0 && "is-top", /center$/.test(pos) && "is-center"),
3298
+ style: offset != null ? { "--aura-toaster-offset": typeof offset === "number" ? offset + "px" : offset } : void 0,
3180
3299
  role: "region",
3181
3300
  "aria-live": "polite",
3182
- "aria-label": t.notifications
3301
+ "aria-label": hotkey ? t.notifications + " (" + hotkey + ")" : t.notifications,
3302
+ "aria-keyshortcuts": hotkey || void 0
3183
3303
  },
3184
3304
  s[0].map(function(t2) {
3185
3305
  return /* @__PURE__ */ React19.createElement(ToastItem, { key: t2.id, toast: t2 });
@@ -4007,7 +4127,27 @@ window.Aura = (() => {
4007
4127
  );
4008
4128
  const sort = sortState[0], setSort = sortState[1];
4009
4129
  const selState = useMaybeControlled(props.selected, props.defaultSelected || [], props.onSelectionChange);
4010
- const selected = selState[0], setSelected = selState[1];
4130
+ const canSelect = function(r) {
4131
+ return !!r && (!props.isRowSelectable || !!props.isRowSelectable(r));
4132
+ };
4133
+ const rejected = {};
4134
+ if (props.isRowSelectable)
4135
+ rows.forEach(function(r) {
4136
+ if (!canSelect(r)) rejected[String(r[rowKey])] = true;
4137
+ });
4138
+ const selected = selState[0].filter(function(k) {
4139
+ return !rejected[String(k)];
4140
+ }), setSelected = selState[1];
4141
+ const droppedSig = selState[0].length === selected.length ? "" : "#" + selState[0].filter(function(k) {
4142
+ return rejected[String(k)];
4143
+ }).join("\0") + "#" + selected.join("\0");
4144
+ React28.useEffect(
4145
+ function() {
4146
+ if (droppedSig !== "") setSelected(selected);
4147
+ },
4148
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4149
+ [droppedSig]
4150
+ );
4011
4151
  const pageState = useMaybeControlled(
4012
4152
  props.page,
4013
4153
  props.defaultPage || 1,
@@ -4418,7 +4558,7 @@ window.Aura = (() => {
4418
4558
  el.focus();
4419
4559
  }
4420
4560
  }
4421
- const pageKeys = pageRows.map(function(r) {
4561
+ const pageKeys = pageRows.filter(canSelect).map(function(r) {
4422
4562
  return r[rowKey];
4423
4563
  });
4424
4564
  const selSet = {};
@@ -4682,10 +4822,12 @@ window.Aura = (() => {
4682
4822
  if (inner) inner.focus();
4683
4823
  else handled = false;
4684
4824
  } else if (k === " " || k === "Enter") {
4685
- if (r === 0 && !col && selectable && nRows) toggleAll();
4686
- else if (r === 0 && col && col.sortable && canSortAny) sortBy(col.key);
4687
- else if (r > 0 && k === " " && selectable) toggle(row[rowKey], !selSet[row[rowKey]]);
4688
- else if (r > 0 && k === "Enter" && rowHref && !target.querySelector("button, a[href]:not(.aura-table__row-link), input, select, textarea"))
4825
+ if (r === 0 && !col && selectable && nRows) {
4826
+ if (pageKeys.length) toggleAll();
4827
+ } else if (r === 0 && col && col.sortable && canSortAny) sortBy(col.key);
4828
+ else if (r > 0 && k === " " && selectable) {
4829
+ if (canSelect(row)) toggle(row[rowKey], !selSet[row[rowKey]]);
4830
+ } else if (r > 0 && k === "Enter" && rowHref && !target.querySelector("button, a[href]:not(.aura-table__row-link), input, select, textarea"))
4689
4831
  followRow(target.closest('[role="row"]'), e);
4690
4832
  else if (r > 0 && k === "Enter" && target.querySelector("button, a[href], input, select, textarea"))
4691
4833
  target.querySelector("button, a[href], input, select, textarea").focus();
@@ -4714,7 +4856,7 @@ window.Aura = (() => {
4714
4856
  activeState[1]({ r: 0, c: 0 });
4715
4857
  }
4716
4858
  },
4717
- nRows ? /* @__PURE__ */ React28.createElement(
4859
+ pageKeys.length ? /* @__PURE__ */ React28.createElement(
4718
4860
  Checkbox,
4719
4861
  {
4720
4862
  hideLabel: true,
@@ -4725,7 +4867,7 @@ window.Aura = (() => {
4725
4867
  onChange: toggleAll
4726
4868
  }
4727
4869
  ) : null,
4728
- nRows ? /* @__PURE__ */ React28.createElement("span", { className: "aura-table__sel-text" }, nSel ? t.selectedCount(nSel) : t.selectAll) : /* @__PURE__ */ React28.createElement("span", { className: "aura-sr-only" }, t.selectRows)
4870
+ pageKeys.length ? /* @__PURE__ */ React28.createElement("span", { className: "aura-table__sel-text" }, nSel ? t.selectedCount(nSel) : t.selectAll) : /* @__PURE__ */ React28.createElement("span", { className: "aura-sr-only" }, t.selectRows)
4729
4871
  )
4730
4872
  );
4731
4873
  vis.forEach(function(c, i) {
@@ -4863,12 +5005,12 @@ window.Aura = (() => {
4863
5005
  className: cx("aura-table__sel", nPinned && "is-pinned"),
4864
5006
  tabIndex: tabFor(ri, 0),
4865
5007
  "data-rc": ri + ":0",
4866
- "aria-label": t.selectRow(k),
5008
+ "aria-label": canSelect(r) ? t.selectRow(k) : props.rowSelectDisabledLabel ? props.rowSelectDisabledLabel(r) : void 0,
4867
5009
  onFocus: function(e) {
4868
5010
  if (e.target === e.currentTarget) activeState[1]({ r: ri, c: 0 });
4869
5011
  }
4870
5012
  },
4871
- /* @__PURE__ */ React28.createElement(
5013
+ canSelect(r) ? /* @__PURE__ */ React28.createElement(
4872
5014
  Checkbox,
4873
5015
  {
4874
5016
  hideLabel: true,
@@ -4879,7 +5021,7 @@ window.Aura = (() => {
4879
5021
  toggle(k, on);
4880
5022
  }
4881
5023
  }
4882
- )
5024
+ ) : null
4883
5025
  )
4884
5026
  );
4885
5027
  vis.forEach(function(c, j) {
@@ -4982,7 +5124,7 @@ window.Aura = (() => {
4982
5124
  key: k,
4983
5125
  role: "row",
4984
5126
  "aria-rowindex": first + i + 2,
4985
- "aria-selected": selectable ? isSel : void 0,
5127
+ "aria-selected": selectable && canSelect(r) ? isSel : void 0,
4986
5128
  className: cx(
4987
5129
  "aura-table__row",
4988
5130
  isSel && "is-selected",