@nkootstra/block-kit 0.3.5 → 0.4.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/dist/index.js CHANGED
@@ -50,12 +50,12 @@ function toViewOutput(view, state, identity) {
50
50
  bot_id: "B00000000",
51
51
  callback_id: view.callback_id ?? "",
52
52
  type: view.type,
53
- title: view.title ?? null,
54
- close: view.close ?? null,
55
- submit: view.submit ?? null,
56
- blocks: view.blocks,
53
+ title: normalizeText(view.title ?? null),
54
+ close: normalizeText(view.close ?? null),
55
+ submit: normalizeText(view.submit ?? null),
56
+ blocks: normalizeBlocks(view.blocks),
57
57
  private_metadata: view.private_metadata ?? "",
58
- state: { values: state },
58
+ state: { values: normalizeState(state) },
59
59
  hash: view.hash ?? "0.0000000000",
60
60
  clear_on_close: view.clear_on_close ?? false,
61
61
  notify_on_close: view.notify_on_close ?? false,
@@ -112,6 +112,35 @@ function normalizeEchoes(entry) {
112
112
  function normalizeState(state) {
113
113
  return Object.fromEntries(Object.entries(state).map(([blockId, elements]) => [blockId, Object.fromEntries(Object.entries(elements).map(([actionId, value]) => [actionId, normalizeEchoes(value)]))]));
114
114
  }
115
+ /**
116
+ * Blocks as Slack echoes a stored view: every text object normalized like {@link normalizeText},
117
+ * and an input block's `optional` and `dispatch_action` filled in as `false`.
118
+ */
119
+ function normalizeBlocks(blocks) {
120
+ return normalizeBlockNode(blocks);
121
+ }
122
+ function normalizeBlockNode(node) {
123
+ if (Array.isArray(node)) return node.map(normalizeBlockNode);
124
+ if (typeof node !== "object" || node === null) return node;
125
+ const object = node;
126
+ if ((object.type === "plain_text" || object.type === "mrkdwn") && typeof object.text === "string") return normalizeText(object);
127
+ const out = Object.fromEntries(Object.entries(object).map(([key, value]) => [key, normalizeBlockNode(value)]));
128
+ if (object.type === "input") {
129
+ out.optional ??= false;
130
+ out.dispatch_action ??= false;
131
+ }
132
+ return out;
133
+ }
134
+ /** `enterprise` and `is_enterprise_install`, as every interaction payload carries them. */
135
+ function enterpriseFields(id) {
136
+ return {
137
+ enterprise: id.team?.enterprise_id ? {
138
+ id: id.team.enterprise_id,
139
+ name: id.team.enterprise_name ?? ""
140
+ } : null,
141
+ is_enterprise_install: id.isEnterpriseInstall
142
+ };
143
+ }
115
144
  function buildBlockActionsPayload({ action, state, container, identity }) {
116
145
  const id = resolveIdentity(identity);
117
146
  state = normalizeState(state);
@@ -119,11 +148,7 @@ function buildBlockActionsPayload({ action, state, container, identity }) {
119
148
  type: "block_actions",
120
149
  actions: [normalizeEchoes(action)],
121
150
  team: id.team,
122
- enterprise: id.team?.enterprise_id ? {
123
- id: id.team.enterprise_id,
124
- name: id.team.enterprise_name ?? ""
125
- } : null,
126
- is_enterprise_install: id.isEnterpriseInstall,
151
+ ...enterpriseFields(id),
127
152
  user: id.user,
128
153
  token: id.token,
129
154
  response_url: id.responseUrl,
@@ -163,6 +188,7 @@ function buildViewSubmissionPayload({ view, state, identity, responseUrls }) {
163
188
  return {
164
189
  type: "view_submission",
165
190
  team: id.team,
191
+ ...enterpriseFields(id),
166
192
  user: {
167
193
  id: id.user.id,
168
194
  name: id.user.name ?? id.user.username ?? "",
@@ -181,6 +207,7 @@ function buildViewClosedPayload({ view, state, identity, isCleared }) {
181
207
  return {
182
208
  type: "view_closed",
183
209
  team: id.team,
210
+ ...enterpriseFields(id),
184
211
  user: {
185
212
  id: id.user.id,
186
213
  name: id.user.name ?? id.user.username ?? "",
@@ -203,7 +230,8 @@ function buildBlockSuggestionPayload({ actionId, blockId, value, container, stat
203
230
  action_id: actionId,
204
231
  block_id: blockId,
205
232
  value,
206
- team: id.team
233
+ team: id.team,
234
+ ...enterpriseFields(id)
207
235
  };
208
236
  if (container.type === "message") return {
209
237
  ...base,
@@ -1040,10 +1068,10 @@ function BlockKitProvider(props) {
1040
1068
  containerRef.current = container;
1041
1069
  }, []);
1042
1070
  const dispatch = useCallback((action, scope) => {
1043
- const full = {
1071
+ const full = normalizeEchoes({
1044
1072
  ...action,
1045
1073
  action_ts: action.action_ts ?? actionTs()
1046
- };
1074
+ });
1047
1075
  const message = scope?.message;
1048
1076
  onAction?.(full, {
1049
1077
  state: stateRef.current,
@@ -5982,33 +6010,89 @@ function Text({ text, emojiSize = 22 }) {
5982
6010
  }
5983
6011
  //#endregion
5984
6012
  //#region src/confirm/ConfirmDialog.tsx
5985
- /** Slack's confirmation dialog: blocks the triggering action until the user picks confirm/deny. */
6013
+ /**
6014
+ * Slack's confirmation dialog (`c-dialog`): blocks the triggering action until the user confirms or
6015
+ * denies. Escape, the close button and a click on the overlay all deny.
6016
+ *
6017
+ * Slack marks it `role="dialog"`; this keeps `alertdialog`, the role WAI-ARIA gives a dialog that
6018
+ * interrupts to ask for a confirmation, so screen readers announce its text on open. Focus starts
6019
+ * on the deny button, the least destructive choice, stays inside while it's open, and goes back to
6020
+ * whatever opened it when it closes.
6021
+ */
5986
6022
  function ConfirmDialog({ confirm, onConfirm, onDeny }) {
5987
6023
  const { theme } = useBlockKit();
5988
6024
  const confirmStyle = confirm.style ?? "primary";
6025
+ const titleId = useId();
6026
+ const textId = useId();
6027
+ const dialogRef = useRef(null);
6028
+ const denyRef = useRef(null);
6029
+ useEffect(() => {
6030
+ const opener = document.activeElement;
6031
+ denyRef.current?.focus();
6032
+ return () => {
6033
+ if (opener?.isConnected) opener.focus();
6034
+ };
6035
+ }, []);
6036
+ function onKeyDown(event) {
6037
+ if (event.key === "Escape") {
6038
+ event.stopPropagation();
6039
+ onDeny();
6040
+ return;
6041
+ }
6042
+ if (event.key !== "Tab") return;
6043
+ const buttons = [...dialogRef.current?.querySelectorAll("button") ?? []];
6044
+ const first = buttons[0];
6045
+ const last = buttons.at(-1);
6046
+ if (event.shiftKey && document.activeElement === first) {
6047
+ event.preventDefault();
6048
+ last?.focus();
6049
+ } else if (!event.shiftKey && document.activeElement === last) {
6050
+ event.preventDefault();
6051
+ first?.focus();
6052
+ }
6053
+ }
5989
6054
  const node = /* @__PURE__ */ jsx("div", {
5990
6055
  className: "sbk-confirm__overlay",
5991
6056
  role: "presentation",
5992
6057
  "data-theme": theme,
5993
6058
  onClick: onDeny,
5994
6059
  children: /* @__PURE__ */ jsxs("div", {
6060
+ ref: dialogRef,
5995
6061
  className: "sbk-confirm",
5996
6062
  role: "alertdialog",
5997
6063
  "aria-modal": "true",
5998
- "aria-label": confirm.title?.text,
6064
+ "aria-labelledby": confirm.title ? titleId : void 0,
6065
+ "aria-label": confirm.title ? void 0 : confirm.confirm?.text ?? "Confirm",
6066
+ "aria-describedby": textId,
6067
+ onKeyDown,
5999
6068
  onClick: (event) => event.stopPropagation(),
6000
6069
  children: [
6001
- confirm.title && /* @__PURE__ */ jsx("h2", {
6002
- className: "sbk-confirm__title",
6003
- children: confirm.title.text
6070
+ /* @__PURE__ */ jsxs("div", {
6071
+ className: "sbk-confirm__header",
6072
+ children: [confirm.title && /* @__PURE__ */ jsx("h2", {
6073
+ id: titleId,
6074
+ className: "sbk-confirm__title",
6075
+ children: confirm.title.text
6076
+ }), /* @__PURE__ */ jsx("button", {
6077
+ type: "button",
6078
+ className: "sbk-confirm__close",
6079
+ "aria-label": "Close",
6080
+ onClick: onDeny,
6081
+ children: /* @__PURE__ */ jsx(CloseIcon, {
6082
+ width: "20",
6083
+ height: "20"
6084
+ })
6085
+ })]
6004
6086
  }),
6005
6087
  /* @__PURE__ */ jsx("div", {
6088
+ id: textId,
6006
6089
  className: "sbk-confirm__text",
6007
6090
  children: /* @__PURE__ */ jsx(Text, { text: confirm.text })
6008
6091
  }),
6009
6092
  /* @__PURE__ */ jsxs("div", {
6010
6093
  className: "sbk-confirm__actions",
6011
6094
  children: [/* @__PURE__ */ jsx("button", {
6095
+ ref: denyRef,
6012
6096
  type: "button",
6013
6097
  className: "sbk-confirm__button sbk-confirm__button--deny",
6014
6098
  onClick: onDeny,
@@ -6181,31 +6265,107 @@ function todayISO() {
6181
6265
  const now = /* @__PURE__ */ new Date();
6182
6266
  return toISO(now.getFullYear(), now.getMonth(), now.getDate());
6183
6267
  }
6184
- function Caret({ direction }) {
6185
- return /* @__PURE__ */ jsx("svg", {
6186
- viewBox: "0 0 20 20",
6187
- width: "20",
6188
- height: "20",
6189
- "aria-hidden": "true",
6190
- children: /* @__PURE__ */ jsx("path", {
6191
- fill: "currentColor",
6192
- fillRule: "evenodd",
6193
- d: direction === "left" ? "M12.28 5.22a.75.75 0 0 1 0 1.06L8.56 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0" : "M7.72 14.78a.75.75 0 0 1 0-1.06L11.44 10 7.72 6.28a.75.75 0 0 1 1.06-1.06l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0",
6194
- clipRule: "evenodd"
6268
+ /** Slack's calendar header icons, 20×20. */
6269
+ const NAV_PATHS = {
6270
+ "previous-year": "M11.03 2.78a.75.75 0 1 0-1.06-1.06L2.22 9.47a.75.75 0 0 0 0 1.06l7.75 7.75a.75.75 0 1 0 1.06-1.06L3.81 10zm6.25 0a.75.75 0 0 0-1.06-1.06L8.47 9.47a.75.75 0 0 0 0 1.06l7.75 7.75a.75.75 0 1 0 1.06-1.06L10.06 10z",
6271
+ "previous-month": "M14.53 1.72a.75.75 0 0 1 0 1.06L7.31 10l7.22 7.22a.75.75 0 1 1-1.06 1.06l-7.75-7.75a.75.75 0 0 1 0-1.06l7.75-7.75a.75.75 0 0 1 1.06 0",
6272
+ "next-month": "M5.72 1.72a.75.75 0 0 1 1.06 0l7.75 7.75a.75.75 0 0 1 0 1.06l-7.75 7.75a.75.75 0 0 1-1.06-1.06L12.94 10 5.72 2.78a.75.75 0 0 1 0-1.06",
6273
+ "next-year": "M3.78 1.72a.75.75 0 0 0-1.06 1.06L9.94 10l-7.22 7.22a.75.75 0 1 0 1.06 1.06l7.75-7.75a.75.75 0 0 0 0-1.06zm6.25 0a.75.75 0 1 0-1.06 1.06L16.19 10l-7.22 7.22a.75.75 0 1 0 1.06 1.06l7.75-7.75a.75.75 0 0 0 0-1.06z"
6274
+ };
6275
+ /** A header button that moves the calendar by `months`. */
6276
+ function NavButton({ icon, label, onClick }) {
6277
+ return /* @__PURE__ */ jsx("button", {
6278
+ type: "button",
6279
+ className: "sbk-calendar__nav",
6280
+ onClick,
6281
+ "aria-label": label,
6282
+ children: /* @__PURE__ */ jsx("svg", {
6283
+ viewBox: "0 0 20 20",
6284
+ width: "20",
6285
+ height: "20",
6286
+ "aria-hidden": "true",
6287
+ children: /* @__PURE__ */ jsx("path", {
6288
+ fill: "currentColor",
6289
+ fillRule: "evenodd",
6290
+ clipRule: "evenodd",
6291
+ d: NAV_PATHS[icon]
6292
+ })
6195
6293
  })
6196
6294
  });
6197
6295
  }
6296
+ /** The day `delta` days after the `YYYY-MM-DD` date, as `YYYY-MM-DD`. */
6297
+ function addDays(iso, delta) {
6298
+ const d = /* @__PURE__ */ new Date(`${iso}T00:00:00Z`);
6299
+ d.setUTCDate(d.getUTCDate() + delta);
6300
+ return toISO(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
6301
+ }
6302
+ /** Days the arrow keys move the focused day: a day sideways, a week up or down. */
6303
+ const ARROW_DAYS = {
6304
+ ArrowLeft: -1,
6305
+ ArrowRight: 1,
6306
+ ArrowUp: -7,
6307
+ ArrowDown: 7
6308
+ };
6198
6309
  /** A month grid matching Slack's `c-date_picker_calendar`: a bordered 7-column grid of 44×42
6199
- * day cells, a ring around today, and solid blue for the selected day. */
6310
+ * day cells, a ring around today, and solid blue for the selected day. The grid is one tab stop:
6311
+ * the focused day (the selected one, else today, else the 1st) takes Tab, and the arrow keys move
6312
+ * it a day or a week, turning the month when they cross its edge. */
6200
6313
  function Calendar({ value, onSelect, onClear }) {
6201
6314
  const initial = value ? /* @__PURE__ */ new Date(`${value}T00:00:00Z`) : /* @__PURE__ */ new Date();
6202
6315
  const [year, setYear] = useState(initial.getUTCFullYear());
6203
6316
  const [month, setMonth] = useState(initial.getUTCMonth());
6317
+ const [focused, setFocused] = useState(() => {
6318
+ if (value) return value;
6319
+ const today = todayISO();
6320
+ return today.startsWith(toISO(year, month, 1).slice(0, 7)) ? today : toISO(year, month, 1);
6321
+ });
6322
+ const gridRef = useRef(null);
6323
+ const [keyboard, setKeyboard] = useState(false);
6324
+ const moveFocus = useRef(false);
6325
+ useEffect(() => {
6326
+ if (!moveFocus.current) return;
6327
+ moveFocus.current = false;
6328
+ gridRef.current?.querySelector(`[data-date="${focused}"]`)?.focus();
6329
+ }, [
6330
+ focused,
6331
+ year,
6332
+ month
6333
+ ]);
6334
+ function onGridKeyDown(e) {
6335
+ const delta = ARROW_DAYS[e.key];
6336
+ if (delta === void 0) return;
6337
+ e.preventDefault();
6338
+ setKeyboard(true);
6339
+ const next = addDays(focused, delta);
6340
+ const [y = year, m = month + 1] = next.split("-").map(Number);
6341
+ moveFocus.current = true;
6342
+ setFocused(next);
6343
+ if (y !== year || m - 1 !== month) {
6344
+ setYear(y);
6345
+ setMonth(m - 1);
6346
+ }
6347
+ }
6348
+ const shown = toISO(year, month, 1).slice(0, 7);
6349
+ const tabStop = focused.startsWith(shown) ? focused : toISO(year, month, 1);
6204
6350
  const first = new Date(Date.UTC(year, month, 1));
6205
6351
  const startWeekday = first.getUTCDay();
6206
6352
  const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
6207
6353
  const cells = [...Array(startWeekday).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1)];
6208
6354
  const today = todayISO();
6355
+ const lastRow = Math.floor((startWeekday + daysInMonth - 1) / 7);
6356
+ const lastRowFull = (startWeekday + daysInMonth) % 7 === 0;
6357
+ /** The grid's outer corners a day sits on, which Slack rounds. */
6358
+ function corners(day) {
6359
+ const at = startWeekday + day - 1;
6360
+ const row = Math.floor(at / 7);
6361
+ const col = at % 7;
6362
+ const out = [];
6363
+ if (day === 1 || row === 1 && col === 0) out.push("top-left");
6364
+ if (row === 0 && col === 6) out.push("top-right");
6365
+ if (row === lastRow && col === 0) out.push("bottom-left");
6366
+ if (day === daysInMonth || !lastRowFull && row === lastRow - 1 && col === 6) out.push("bottom-right");
6367
+ return out;
6368
+ }
6209
6369
  function go(delta) {
6210
6370
  const d = new Date(Date.UTC(year, month + delta, 1));
6211
6371
  setYear(d.getUTCFullYear());
@@ -6216,50 +6376,59 @@ function Calendar({ value, onSelect, onClear }) {
6216
6376
  year: "numeric",
6217
6377
  timeZone: "UTC"
6218
6378
  });
6219
- return /* @__PURE__ */ jsxs("div", {
6379
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
6220
6380
  className: "sbk-calendar",
6221
6381
  children: [
6222
6382
  /* @__PURE__ */ jsxs("div", {
6223
6383
  className: "sbk-calendar__header",
6224
6384
  children: [
6225
- /* @__PURE__ */ jsx("button", {
6226
- type: "button",
6227
- className: "sbk-calendar__nav",
6228
- onClick: () => go(-1),
6229
- "aria-label": "Previous month",
6230
- children: /* @__PURE__ */ jsx(Caret, { direction: "left" })
6385
+ /* @__PURE__ */ jsx(NavButton, {
6386
+ icon: "previous-year",
6387
+ label: "Previous year",
6388
+ onClick: () => go(-12)
6389
+ }),
6390
+ /* @__PURE__ */ jsx(NavButton, {
6391
+ icon: "previous-month",
6392
+ label: "Previous month",
6393
+ onClick: () => go(-1)
6231
6394
  }),
6232
6395
  /* @__PURE__ */ jsx("span", {
6233
6396
  className: "sbk-calendar__label",
6234
6397
  children: monthLabel
6235
6398
  }),
6236
- /* @__PURE__ */ jsx("button", {
6237
- type: "button",
6238
- className: "sbk-calendar__nav",
6239
- onClick: () => go(1),
6240
- "aria-label": "Next month",
6241
- children: /* @__PURE__ */ jsx(Caret, { direction: "right" })
6399
+ /* @__PURE__ */ jsx(NavButton, {
6400
+ icon: "next-month",
6401
+ label: "Next month",
6402
+ onClick: () => go(1)
6403
+ }),
6404
+ /* @__PURE__ */ jsx(NavButton, {
6405
+ icon: "next-year",
6406
+ label: "Next year",
6407
+ onClick: () => go(12)
6242
6408
  })
6243
6409
  ]
6244
6410
  }),
6245
6411
  /* @__PURE__ */ jsx("div", {
6246
6412
  className: "sbk-calendar__weekdays",
6247
6413
  children: [
6248
- "Sun",
6249
- "Mon",
6250
- "Tue",
6251
- "Wed",
6252
- "Thu",
6253
- "Fri",
6254
- "Sat"
6414
+ "Su",
6415
+ "Mo",
6416
+ "Tu",
6417
+ "We",
6418
+ "Th",
6419
+ "Fr",
6420
+ "Sa"
6255
6421
  ].map((w) => /* @__PURE__ */ jsx("span", { children: w }, w))
6256
6422
  }),
6257
6423
  /* @__PURE__ */ jsx("div", {
6258
- className: "sbk-calendar__grid",
6424
+ className: `sbk-calendar__grid${keyboard ? " sbk-calendar__grid--keyboard" : ""}`,
6425
+ ref: gridRef,
6426
+ onKeyDown: onGridKeyDown,
6427
+ onPointerDown: () => setKeyboard(false),
6259
6428
  children: cells.map((day, i) => {
6260
6429
  if (day === null) return /* @__PURE__ */ jsx("span", { className: "sbk-calendar__cell sbk-calendar__cell--empty" }, i);
6261
6430
  const iso = toISO(year, month, day);
6262
- const classes = ["sbk-calendar__cell"];
6431
+ const classes = ["sbk-calendar__cell", ...corners(day).map((c) => `sbk-calendar__cell--${c}`)];
6263
6432
  if (iso === value) classes.push("sbk-calendar__cell--selected");
6264
6433
  if (iso === today) classes.push("sbk-calendar__cell--today");
6265
6434
  return /* @__PURE__ */ jsx("button", {
@@ -6267,22 +6436,24 @@ function Calendar({ value, onSelect, onClear }) {
6267
6436
  className: classes.join(" "),
6268
6437
  "aria-pressed": iso === value,
6269
6438
  "aria-current": iso === today ? "date" : void 0,
6439
+ "data-date": iso,
6440
+ tabIndex: iso === tabStop ? 0 : -1,
6441
+ onFocus: () => setFocused(iso),
6270
6442
  onClick: () => onSelect(iso),
6271
6443
  children: day
6272
6444
  }, iso);
6273
6445
  })
6274
- }),
6275
- onClear && value && /* @__PURE__ */ jsx("div", {
6276
- className: "sbk-calendar__footer",
6277
- children: /* @__PURE__ */ jsx("button", {
6278
- type: "button",
6279
- className: "sbk-calendar__clear",
6280
- onClick: onClear,
6281
- children: "Clear selection"
6282
- })
6283
6446
  })
6284
6447
  ]
6285
- });
6448
+ }), onClear && value && /* @__PURE__ */ jsx("div", {
6449
+ className: "sbk-calendar__footer",
6450
+ children: /* @__PURE__ */ jsx("button", {
6451
+ type: "button",
6452
+ className: "sbk-calendar__clear",
6453
+ onClick: onClear,
6454
+ children: "Clear selection"
6455
+ })
6456
+ })] });
6286
6457
  }
6287
6458
  //#endregion
6288
6459
  //#region src/elements/dateFormat.ts
@@ -6381,6 +6552,11 @@ const InputBlockContext = createContext(false);
6381
6552
  function useInInputBlock() {
6382
6553
  return useContext(InputBlockContext);
6383
6554
  }
6555
+ /** True while rendering the element of an `input` block marked `optional`, which Slack lets the user empty. */
6556
+ const InputOptionalContext = createContext(false);
6557
+ function useInOptionalInput() {
6558
+ return useContext(InputOptionalContext);
6559
+ }
6384
6560
  /** The id of the error shown under an `input` block's element, while it has one. */
6385
6561
  const InputErrorContext = createContext(void 0);
6386
6562
  /**
@@ -6398,14 +6574,18 @@ function useInvalidProps() {
6398
6574
  //#region src/elements/Popover.tsx
6399
6575
  /** Space between the anchor and the popover, as in Slack's menus. */
6400
6576
  const DEFAULT_GAP = 4;
6577
+ /** The least space kept between a popover and the window's left and right edges. */
6578
+ const EDGE = 8;
6401
6579
  /**
6402
6580
  * The layer a menu or calendar opens in. Slack mounts its popovers at the top of the page, so a
6403
6581
  * message list, a modal body or a carousel never clips them; this portals to `<body>` the same way
6404
6582
  * and keeps the popover pinned to its anchor as the page scrolls or resizes, or its content does.
6583
+ * Where keeping its alignment would run it past the window's left or right edge (a narrow phone
6584
+ * screen), it shifts just enough to stay inside.
6405
6585
  * The layer is as wide as the anchor, so a child sized `width: 100%` matches the control it opened
6406
6586
  * from.
6407
6587
  */
6408
- function Popover({ anchorRef, onDismiss, gap = DEFAULT_GAP, children }) {
6588
+ function Popover({ anchorRef, onDismiss, gap = DEFAULT_GAP, offsetX = 0, onPlaced, children }) {
6409
6589
  const { theme } = useBlockKit();
6410
6590
  const layerRef = useRef(null);
6411
6591
  const [position, setPosition] = useState(null);
@@ -6426,9 +6606,19 @@ function Popover({ anchorRef, onDismiss, gap = DEFAULT_GAP, children }) {
6426
6606
  const fitsAbove = above >= 0;
6427
6607
  let top = !fitsBelow && fitsAbove ? above : below;
6428
6608
  if (!fitsBelow && !fitsAbove) top = Math.max(gap, Math.min(below, window.innerHeight - height - gap));
6609
+ layer.style.width = `${a.width}px`;
6610
+ const content = layer.firstElementChild?.getBoundingClientRect();
6611
+ const layerLeft = layer.getBoundingClientRect().left;
6612
+ let left = a.left + offsetX;
6613
+ if (content) {
6614
+ const contentLeft = left + content.left - layerLeft;
6615
+ const room = document.documentElement.clientWidth - EDGE;
6616
+ const shift = Math.max(EDGE - contentLeft, Math.min(0, room - (contentLeft + content.width)));
6617
+ left += shift;
6618
+ }
6429
6619
  setPosition({
6430
6620
  top,
6431
- left: a.left,
6621
+ left,
6432
6622
  width: a.width
6433
6623
  });
6434
6624
  }
@@ -6443,7 +6633,18 @@ function Popover({ anchorRef, onDismiss, gap = DEFAULT_GAP, children }) {
6443
6633
  window.removeEventListener("resize", place);
6444
6634
  resize?.disconnect();
6445
6635
  };
6446
- }, [anchorRef, gap]);
6636
+ }, [
6637
+ anchorRef,
6638
+ gap,
6639
+ offsetX
6640
+ ]);
6641
+ const placed = position !== null;
6642
+ const notified = useRef(false);
6643
+ useEffect(() => {
6644
+ if (!placed || notified.current) return;
6645
+ notified.current = true;
6646
+ onPlaced?.();
6647
+ }, [placed]);
6447
6648
  useEffect(() => {
6448
6649
  function onDocDown(e) {
6449
6650
  const target = e.target;
@@ -6483,14 +6684,27 @@ function formatDate$1(date, long) {
6483
6684
  function DatePicker({ element, blockId }) {
6484
6685
  const { setValue, dispatch } = useBlockKit();
6485
6686
  const inInputBlock = useInInputBlock();
6687
+ const clearable = useInOptionalInput();
6486
6688
  const { ask, dialog } = useConfirm(element.confirm);
6487
6689
  const [date, setDate] = useState(element.initial_date);
6488
6690
  const [open, setOpen] = useState(false);
6489
6691
  const rootRef = useRef(null);
6490
6692
  const inputRef = useFocusOnLoad(element);
6491
6693
  const invalid = useInvalidProps();
6492
- const loadFocus = useRef(element.focus_on_load === true);
6694
+ const quietFocus = useRef(element.focus_on_load === true);
6695
+ const focusCalendar = useRef(false);
6696
+ const popupRef = useRef(null);
6493
6697
  const actionId = element.action_id ?? "";
6698
+ function focusDay() {
6699
+ popupRef.current?.querySelector(".sbk-calendar__grid [tabindex='0']")?.focus();
6700
+ }
6701
+ function close() {
6702
+ setOpen(false);
6703
+ const input = inputRef.current;
6704
+ if (!input || document.activeElement === input) return;
6705
+ quietFocus.current = true;
6706
+ input.focus();
6707
+ }
6494
6708
  useEffect(() => {
6495
6709
  if (date) setValue(blockId, actionId, {
6496
6710
  type: "datepicker",
@@ -6500,7 +6714,7 @@ function DatePicker({ element, blockId }) {
6500
6714
  async function pick(next) {
6501
6715
  if (!await ask()) return;
6502
6716
  setDate(next);
6503
- setOpen(false);
6717
+ close();
6504
6718
  setValue(blockId, actionId, {
6505
6719
  type: "datepicker",
6506
6720
  selected_date: next
@@ -6516,7 +6730,7 @@ function DatePicker({ element, blockId }) {
6516
6730
  async function clear() {
6517
6731
  if (!await ask()) return;
6518
6732
  setDate(void 0);
6519
- setOpen(false);
6733
+ close();
6520
6734
  setValue(blockId, actionId, {
6521
6735
  type: "datepicker",
6522
6736
  selected_date: null
@@ -6547,12 +6761,27 @@ function DatePicker({ element, blockId }) {
6547
6761
  ...invalid,
6548
6762
  ref: inputRef,
6549
6763
  onFocus: () => {
6550
- if (loadFocus.current) loadFocus.current = false;
6764
+ if (quietFocus.current) quietFocus.current = false;
6551
6765
  else setOpen(true);
6552
6766
  },
6553
- onClick: () => setOpen(true),
6767
+ onMouseDown: () => {
6768
+ focusCalendar.current = true;
6769
+ },
6770
+ onClick: () => {
6771
+ if (open) focusDay();
6772
+ else focusCalendar.current = true;
6773
+ setOpen(true);
6774
+ },
6554
6775
  onKeyDown: (e) => {
6555
6776
  if (e.key === "Escape") setOpen(false);
6777
+ if (e.key === "ArrowDown") {
6778
+ e.preventDefault();
6779
+ if (open) focusDay();
6780
+ else {
6781
+ focusCalendar.current = true;
6782
+ setOpen(true);
6783
+ }
6784
+ }
6556
6785
  }
6557
6786
  }),
6558
6787
  /* @__PURE__ */ jsx(ChevronDownIcon, { className: "sbk-datepicker__chevron" })
@@ -6560,13 +6789,26 @@ function DatePicker({ element, blockId }) {
6560
6789
  }),
6561
6790
  open && /* @__PURE__ */ jsx(Popover, {
6562
6791
  anchorRef: rootRef,
6792
+ gap: -4,
6563
6793
  onDismiss: () => setOpen(false),
6794
+ onPlaced: () => {
6795
+ if (focusCalendar.current) focusDay();
6796
+ focusCalendar.current = false;
6797
+ },
6564
6798
  children: /* @__PURE__ */ jsx("div", {
6565
6799
  className: "sbk-datepicker__popup",
6800
+ ref: popupRef,
6801
+ role: "dialog",
6802
+ "aria-label": "Choose a date",
6803
+ onKeyDown: (e) => {
6804
+ if (e.key !== "Escape") return;
6805
+ e.preventDefault();
6806
+ close();
6807
+ },
6566
6808
  children: /* @__PURE__ */ jsx(Calendar, {
6567
6809
  value: date,
6568
6810
  onSelect: pick,
6569
- onClear: clear
6811
+ onClear: clearable ? clear : void 0
6570
6812
  })
6571
6813
  })
6572
6814
  }),
@@ -6942,9 +7184,11 @@ function Overflow({ element, blockId }) {
6942
7184
  const triggerRef = useRef(null);
6943
7185
  const listRef = useRef(null);
6944
7186
  const actionId = element.action_id ?? "";
7187
+ const idPrefix = useId();
6945
7188
  async function choose(option) {
6946
7189
  if (!await ask()) return;
6947
7190
  setOpen(false);
7191
+ triggerRef.current?.focus();
6948
7192
  dispatch({
6949
7193
  type: "overflow",
6950
7194
  action_id: actionId,
@@ -6985,11 +7229,15 @@ function Overflow({ element, blockId }) {
6985
7229
  open && /* @__PURE__ */ jsx(Popover, {
6986
7230
  anchorRef: rootRef,
6987
7231
  onDismiss: () => setOpen(false),
7232
+ onPlaced: () => listRef.current?.focus(),
6988
7233
  children: /* @__PURE__ */ jsx("div", {
6989
7234
  className: "sbk-overflow__menu",
6990
7235
  role: "menu",
6991
7236
  ref: listRef,
7237
+ tabIndex: -1,
7238
+ "aria-activedescendant": nav.active >= 0 ? `${idPrefix}-${nav.active}` : void 0,
6992
7239
  children: element.options.map((option, i) => /* @__PURE__ */ jsx("div", {
7240
+ id: `${idPrefix}-${i}`,
6993
7241
  role: "menuitem",
6994
7242
  className: "sbk-overflow__option",
6995
7243
  onClick: () => choose(option),
@@ -7280,6 +7528,236 @@ function RichTextInput({ element, blockId }) {
7280
7528
  });
7281
7529
  }
7282
7530
  //#endregion
7531
+ //#region src/elements/useCombobox.ts
7532
+ /**
7533
+ * The typeable trigger Slack's select and time pickers share (`c-select_input`): a text input with
7534
+ * role="combobox" that shows the chosen value while closed, and the query while its list is open.
7535
+ * Typing filters the list and highlights its first row; the arrow keys move the highlight, Enter
7536
+ * picks it, Escape and Tab close the list and put the chosen value back.
7537
+ */
7538
+ function useCombobox({ query, onQueryChange, count, initialIndex, onChoose, onSubmitQuery, listRef, returnFocusRef, open: controlledOpen, onOpenChange }) {
7539
+ const [ownOpen, setOwnOpen] = useState(false);
7540
+ const open = controlledOpen ?? ownOpen;
7541
+ const setOpenState = onOpenChange ?? setOwnOpen;
7542
+ const inputRef = useRef(null);
7543
+ const id = useId();
7544
+ const listId = `${id}-list`;
7545
+ const optionId = (index) => `${id}-option-${index}`;
7546
+ function setOpen(next) {
7547
+ setOpenState(next);
7548
+ if (!next) onQueryChange("");
7549
+ }
7550
+ const nav = useMenuNavigation({
7551
+ open,
7552
+ count,
7553
+ initialIndex,
7554
+ onChoose,
7555
+ onClose: () => {
7556
+ setOpen(false);
7557
+ (returnFocusRef ?? inputRef).current?.focus();
7558
+ },
7559
+ onOpen: () => setOpenState(true),
7560
+ listRef
7561
+ });
7562
+ /** Keys for the input, or for a container around a button trigger and its list. */
7563
+ function onKeyDown(e) {
7564
+ if (e.key === "Tab") {
7565
+ if (open) setOpen(false);
7566
+ return;
7567
+ }
7568
+ if (open && e.key === "Enter" && nav.active < 0 && query.trim()) {
7569
+ e.preventDefault();
7570
+ onSubmitQuery?.(query.trim());
7571
+ return;
7572
+ }
7573
+ nav.onKeyDown(e);
7574
+ }
7575
+ /** Props for the input. `display` is the chosen value, shown while the list is closed. */
7576
+ function inputProps(display, placeholder) {
7577
+ return {
7578
+ ref: inputRef,
7579
+ type: "text",
7580
+ role: "combobox",
7581
+ autoComplete: "off",
7582
+ size: 1,
7583
+ spellCheck: false,
7584
+ "aria-autocomplete": "list",
7585
+ "aria-expanded": open,
7586
+ "aria-controls": open ? listId : void 0,
7587
+ "aria-activedescendant": open && nav.active >= 0 ? optionId(nav.active) : void 0,
7588
+ "aria-label": placeholder,
7589
+ placeholder: open ? display ?? placeholder : placeholder,
7590
+ value: open ? query : display ?? "",
7591
+ onClick: () => setOpenState(true),
7592
+ onChange: (e) => {
7593
+ onQueryChange(e.target.value);
7594
+ setOpenState(true);
7595
+ nav.setActive(0);
7596
+ },
7597
+ onKeyDown
7598
+ };
7599
+ }
7600
+ /** Props for the row at `index`, tying it to the input's aria-activedescendant. */
7601
+ function optionProps(index) {
7602
+ return {
7603
+ id: optionId(index),
7604
+ ...nav.itemProps(index)
7605
+ };
7606
+ }
7607
+ return {
7608
+ open,
7609
+ setOpen,
7610
+ onKeyDown,
7611
+ listId,
7612
+ inputRef,
7613
+ inputProps,
7614
+ optionProps,
7615
+ active: nav.active
7616
+ };
7617
+ }
7618
+ //#endregion
7619
+ //#region src/elements/accessoryContext.ts
7620
+ /**
7621
+ * True for an element rendered as a section block's accessory. Slack draws some elements
7622
+ * differently there: a multi-select becomes a small button that opens a "Select options" dialog.
7623
+ */
7624
+ const SectionAccessoryContext = createContext(false);
7625
+ //#endregion
7626
+ //#region src/elements/SelectDialog.tsx
7627
+ const optionId = (option) => option.value ?? option.text.text;
7628
+ /**
7629
+ * Slack's "Select options" dialog, which a `multi_static_select` in a section accessory opens
7630
+ * instead of an inline menu. Picks are a draft: the option list stays open while choosing, and
7631
+ * nothing is sent until Confirm. Cancel, the close button and Escape discard the draft.
7632
+ */
7633
+ function SelectDialog({ options, initial, placeholder, onConfirm, onCancel }) {
7634
+ const { theme } = useBlockKit();
7635
+ const titleId = useId();
7636
+ const [draft, setDraft] = useState(initial);
7637
+ const [query, setQuery] = useState("");
7638
+ const fieldRef = useRef(null);
7639
+ const listRef = useRef(null);
7640
+ const rows = options.filter((o) => o.text.text.toLowerCase().includes(query.toLowerCase()));
7641
+ const chosen = (option) => draft.some((d) => optionId(d) === optionId(option));
7642
+ const toggle = (option) => setDraft((current) => chosen(option) ? current.filter((d) => optionId(d) !== optionId(option)) : [...current, option]);
7643
+ const combo = useCombobox({
7644
+ query,
7645
+ onQueryChange: setQuery,
7646
+ count: rows.length,
7647
+ initialIndex: 0,
7648
+ onChoose: (i) => {
7649
+ const option = rows[i];
7650
+ if (option) toggle(option);
7651
+ },
7652
+ listRef
7653
+ });
7654
+ useEffect(() => {
7655
+ combo.inputRef.current?.focus();
7656
+ }, []);
7657
+ if (typeof document === "undefined") return null;
7658
+ return createPortal(/* @__PURE__ */ jsx("div", {
7659
+ className: "sbk-root sbk-select-dialog__overlay",
7660
+ "data-theme": theme,
7661
+ children: /* @__PURE__ */ jsxs("div", {
7662
+ className: "sbk-select-dialog",
7663
+ role: "dialog",
7664
+ "aria-modal": "true",
7665
+ "aria-labelledby": titleId,
7666
+ onKeyDown: (e) => {
7667
+ if (e.key !== "Escape") return;
7668
+ e.preventDefault();
7669
+ e.stopPropagation();
7670
+ onCancel();
7671
+ },
7672
+ children: [
7673
+ /* @__PURE__ */ jsxs("div", {
7674
+ className: "sbk-select-dialog__header",
7675
+ children: [/* @__PURE__ */ jsx("h2", {
7676
+ className: "sbk-select-dialog__title",
7677
+ id: titleId,
7678
+ children: "Select options"
7679
+ }), /* @__PURE__ */ jsx("button", {
7680
+ type: "button",
7681
+ className: "sbk-select-dialog__close",
7682
+ "aria-label": "Close",
7683
+ onClick: onCancel,
7684
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
7685
+ })]
7686
+ }),
7687
+ /* @__PURE__ */ jsxs("div", {
7688
+ className: "sbk-select-dialog__body",
7689
+ children: [/* @__PURE__ */ jsxs("div", {
7690
+ className: "sbk-select-dialog__field",
7691
+ ref: fieldRef,
7692
+ children: [draft.map((option) => /* @__PURE__ */ jsxs("span", {
7693
+ className: "sbk-select__chip",
7694
+ children: [/* @__PURE__ */ jsx("span", {
7695
+ className: "sbk-select__chip-label",
7696
+ children: /* @__PURE__ */ jsx("span", {
7697
+ className: "sbk-select__chip-text",
7698
+ children: /* @__PURE__ */ jsx("span", { children: option.text.text })
7699
+ })
7700
+ }), /* @__PURE__ */ jsx("button", {
7701
+ type: "button",
7702
+ className: "sbk-select__chip-remove",
7703
+ "aria-label": `Remove ${option.text.text}`,
7704
+ onClick: () => toggle(option),
7705
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
7706
+ })]
7707
+ }, optionId(option))), /* @__PURE__ */ jsx("input", {
7708
+ ...combo.inputProps(void 0, placeholder),
7709
+ placeholder: draft.length > 0 ? "" : placeholder,
7710
+ className: "sbk-select-dialog__input"
7711
+ })]
7712
+ }), combo.open && /* @__PURE__ */ jsx(Popover, {
7713
+ anchorRef: fieldRef,
7714
+ onDismiss: () => combo.setOpen(false),
7715
+ children: /* @__PURE__ */ jsx("div", {
7716
+ className: "sbk-select__menu sbk-select-dialog__menu",
7717
+ role: "listbox",
7718
+ "aria-multiselectable": "true",
7719
+ id: combo.listId,
7720
+ ref: listRef,
7721
+ children: rows.map((option, i) => /* @__PURE__ */ jsx("div", {
7722
+ role: "option",
7723
+ "aria-selected": chosen(option),
7724
+ className: `sbk-select__option${chosen(option) ? " sbk-select__option--selected" : ""}`,
7725
+ onMouseDown: (e) => e.preventDefault(),
7726
+ onClick: () => toggle(option),
7727
+ ...combo.optionProps(i),
7728
+ children: /* @__PURE__ */ jsx("span", {
7729
+ className: "sbk-select__option-text",
7730
+ children: /* @__PURE__ */ jsx(Text, { text: option.text })
7731
+ })
7732
+ }, optionId(option)))
7733
+ })
7734
+ })]
7735
+ }),
7736
+ /* @__PURE__ */ jsxs("div", {
7737
+ className: "sbk-select-dialog__footer",
7738
+ children: [/* @__PURE__ */ jsx("button", {
7739
+ type: "button",
7740
+ className: "sbk-button",
7741
+ onClick: onCancel,
7742
+ children: /* @__PURE__ */ jsx("span", {
7743
+ className: "sbk-button__label",
7744
+ children: "Cancel"
7745
+ })
7746
+ }), /* @__PURE__ */ jsx("button", {
7747
+ type: "button",
7748
+ className: "sbk-button sbk-button--primary",
7749
+ onClick: () => onConfirm(draft),
7750
+ children: /* @__PURE__ */ jsx("span", {
7751
+ className: "sbk-button__label",
7752
+ children: "Confirm"
7753
+ })
7754
+ })]
7755
+ })
7756
+ ]
7757
+ })
7758
+ }), document.body);
7759
+ }
7760
+ //#endregion
7283
7761
  //#region src/elements/Select.tsx
7284
7762
  function parseType(type) {
7285
7763
  const multi = type.startsWith("multi_");
@@ -7349,7 +7827,6 @@ function Select({ element, blockId }) {
7349
7827
  const [query, setQuery] = useState("");
7350
7828
  const rootRef = useRef(null);
7351
7829
  const triggerRef = useRef(null);
7352
- useFocusOnLoad(element, triggerRef);
7353
7830
  const invalid = useInvalidProps();
7354
7831
  const listRef = useRef(null);
7355
7832
  const remote = source === "external" && loadOptions !== void 0;
@@ -7470,28 +7947,166 @@ function Select({ element, blockId }) {
7470
7947
  })) : [{ options: filteredOptions }];
7471
7948
  const flatOptions = visibleGroups.flatMap((g) => g.options);
7472
7949
  const isSelected = (option) => items.some((i) => i.id === (option.value ?? option.text.text));
7473
- const nav = useMenuNavigation({
7950
+ const showSearchOnly = source !== "static";
7951
+ const typeable = source === "static" && !multi;
7952
+ const dialogMode = useContext(SectionAccessoryContext) && multi && source === "static";
7953
+ const [dialogOpen, setDialogOpen] = useState(false);
7954
+ const combo = useCombobox({
7474
7955
  open,
7956
+ onOpenChange: setOpen,
7957
+ query,
7958
+ onQueryChange: setQuery,
7475
7959
  count: flatOptions.length,
7476
7960
  initialIndex: Math.max(0, flatOptions.findIndex(isSelected)),
7477
7961
  onChoose: (i) => {
7478
7962
  const option = flatOptions[i];
7479
7963
  if (option) selectItem(optionToItem(option));
7480
7964
  },
7481
- onClose: () => {
7482
- setOpen(false);
7483
- triggerRef.current?.focus();
7965
+ onSubmitQuery: (typed) => {
7966
+ if (showSearchOnly && !remote) selectItem({
7967
+ id: typed,
7968
+ label: resolveLabel(typed)
7969
+ });
7484
7970
  },
7485
- onOpen: () => setOpen(true),
7486
- listRef
7971
+ listRef,
7972
+ returnFocusRef: typeable ? void 0 : triggerRef
7487
7973
  });
7974
+ useFocusOnLoad(element, typeable ? combo.inputRef : triggerRef);
7488
7975
  const closedLabel = multi ? void 0 : items[0] ? items[0].label || resolveLabel(items[0].id) : void 0;
7489
7976
  const placeholder = element.placeholder?.text ?? "Select an item";
7490
- const showSearchOnly = source !== "static";
7977
+ const menu = open && /* @__PURE__ */ jsx(Popover, {
7978
+ anchorRef: rootRef,
7979
+ onDismiss: () => combo.setOpen(false),
7980
+ offsetX: typeable ? -12 : 0,
7981
+ gap: -4,
7982
+ children: /* @__PURE__ */ jsxs("div", {
7983
+ className: `sbk-select__menu${typeable ? " sbk-select__menu--typeable" : ""}`,
7984
+ role: "listbox",
7985
+ id: combo.listId,
7986
+ ref: listRef,
7987
+ children: [
7988
+ !typeable && (showSearchOnly || allOptions(element).length > 8) && /* @__PURE__ */ jsxs("div", {
7989
+ className: "sbk-select__search",
7990
+ children: [/* @__PURE__ */ jsx(SearchIcon$1, {}), /* @__PURE__ */ jsx("input", {
7991
+ autoFocus: true,
7992
+ className: "sbk-select__search-input",
7993
+ placeholder: source === "static" || source === "external" ? "Search options" : `Search ${source}`,
7994
+ value: query,
7995
+ onChange: (e) => setQuery(e.target.value)
7996
+ })]
7997
+ }),
7998
+ (() => {
7999
+ let index = 0;
8000
+ return visibleGroups.map((group, gi) => {
8001
+ const rows = group.options.map((option) => {
8002
+ const i = index++;
8003
+ return /* @__PURE__ */ jsx(SelectOption, {
8004
+ option,
8005
+ selected: isSelected(option),
8006
+ onSelect: () => selectItem(optionToItem(option)),
8007
+ navProps: combo.optionProps(i)
8008
+ }, option.value ?? i);
8009
+ });
8010
+ return group.label === void 0 ? rows : /* @__PURE__ */ jsxs("div", {
8011
+ className: "sbk-select__group",
8012
+ children: [/* @__PURE__ */ jsx("div", {
8013
+ className: "sbk-select__group-label",
8014
+ children: group.label
8015
+ }), rows]
8016
+ }, gi);
8017
+ });
8018
+ })(),
8019
+ remote && loading && /* @__PURE__ */ jsx("div", {
8020
+ className: "sbk-select__status",
8021
+ children: "Loading…"
8022
+ }),
8023
+ remote && !loading && remoteGroups !== void 0 && flatOptions.length === 0 && /* @__PURE__ */ jsx("div", {
8024
+ className: "sbk-select__status",
8025
+ children: "No results"
8026
+ }),
8027
+ showSearchOnly && items.length > 0 && multi && /* @__PURE__ */ jsx("div", {
8028
+ className: "sbk-select__group-label",
8029
+ children: "Selected"
8030
+ }),
8031
+ showSearchOnly && multi && items.map((item) => /* @__PURE__ */ jsx("div", {
8032
+ className: "sbk-select__option sbk-select__option--selected",
8033
+ children: item.label || resolveLabel(item.id)
8034
+ }, item.id))
8035
+ ]
8036
+ })
8037
+ });
8038
+ if (dialogMode) {
8039
+ const closeDialog = () => {
8040
+ setDialogOpen(false);
8041
+ triggerRef.current?.focus();
8042
+ };
8043
+ return /* @__PURE__ */ jsxs("div", {
8044
+ className: `sbk-select sbk-select--multi${sizeClass}`,
8045
+ ref: rootRef,
8046
+ children: [
8047
+ /* @__PURE__ */ jsx("button", {
8048
+ ref: triggerRef,
8049
+ type: "button",
8050
+ className: "sbk-select__control",
8051
+ onClick: () => setDialogOpen(true),
8052
+ "aria-haspopup": "dialog",
8053
+ "aria-expanded": dialogOpen,
8054
+ ...invalid,
8055
+ children: /* @__PURE__ */ jsx("span", {
8056
+ className: "sbk-select__placeholder",
8057
+ children: items.length > 0 ? `${items.length} selected` : placeholder
8058
+ })
8059
+ }),
8060
+ dialogOpen && /* @__PURE__ */ jsx(SelectDialog, {
8061
+ options: allOptions(element),
8062
+ initial: items.flatMap((i) => i.option ? [i.option] : []),
8063
+ placeholder,
8064
+ onCancel: closeDialog,
8065
+ onConfirm: async (selected) => {
8066
+ closeDialog();
8067
+ if (!await ask()) return;
8068
+ await commit(selected.map(optionToItem));
8069
+ }
8070
+ }),
8071
+ dialog
8072
+ ]
8073
+ });
8074
+ }
8075
+ if (typeable) {
8076
+ const display = items[0] !== void 0 && isUnresolved(items[0]) ? void 0 : closedLabel;
8077
+ const overlay = display !== void 0 && !open;
8078
+ return /* @__PURE__ */ jsxs("div", {
8079
+ className: `sbk-select sbk-select--typeable${sizeClass}`,
8080
+ ref: rootRef,
8081
+ children: [
8082
+ /* @__PURE__ */ jsxs("label", {
8083
+ className: "sbk-select__control",
8084
+ children: [
8085
+ /* @__PURE__ */ jsx("input", {
8086
+ ...combo.inputProps(display, placeholder),
8087
+ ...invalid,
8088
+ className: `sbk-select__input${overlay ? " sbk-select__input--behind" : ""}`
8089
+ }),
8090
+ /* @__PURE__ */ jsx(ChevronDownIcon, { className: "sbk-select__chevron" }),
8091
+ overlay && /* @__PURE__ */ jsx("span", {
8092
+ className: "sbk-select__content",
8093
+ "aria-hidden": "true",
8094
+ children: /* @__PURE__ */ jsx("span", {
8095
+ className: "sbk-select__content-text",
8096
+ children: display
8097
+ })
8098
+ })
8099
+ ]
8100
+ }),
8101
+ menu,
8102
+ dialog
8103
+ ]
8104
+ });
8105
+ }
7491
8106
  return /* @__PURE__ */ jsxs("div", {
7492
8107
  className: `sbk-select${multi ? " sbk-select--multi" : ""}${multi && items.length > 0 ? " sbk-select--chips" : ""}${sizeClass}`,
7493
8108
  ref: rootRef,
7494
- onKeyDown: nav.onKeyDown,
8109
+ onKeyDown: combo.onKeyDown,
7495
8110
  children: [
7496
8111
  multi && element.max_selected_items && /* @__PURE__ */ jsxs("p", {
7497
8112
  className: "sbk-select__max-info",
@@ -7550,70 +8165,7 @@ function Select({ element, blockId }) {
7550
8165
  children: placeholder
7551
8166
  }), /* @__PURE__ */ jsx(ChevronDownIcon, { className: "sbk-select__chevron" })]
7552
8167
  }),
7553
- open && /* @__PURE__ */ jsx(Popover, {
7554
- anchorRef: rootRef,
7555
- onDismiss: () => setOpen(false),
7556
- children: /* @__PURE__ */ jsxs("div", {
7557
- className: "sbk-select__menu",
7558
- role: "listbox",
7559
- ref: listRef,
7560
- children: [
7561
- (showSearchOnly || allOptions(element).length > 8) && /* @__PURE__ */ jsxs("div", {
7562
- className: "sbk-select__search",
7563
- children: [/* @__PURE__ */ jsx(SearchIcon$1, {}), /* @__PURE__ */ jsx("input", {
7564
- autoFocus: true,
7565
- className: "sbk-select__search-input",
7566
- placeholder: source === "static" || source === "external" ? "Search options" : `Search ${source}`,
7567
- value: query,
7568
- onChange: (e) => setQuery(e.target.value),
7569
- onKeyDown: (e) => {
7570
- if (e.key === "Enter" && query.trim() && showSearchOnly && !remote) selectItem({
7571
- id: query.trim(),
7572
- label: resolveLabel(query.trim())
7573
- });
7574
- }
7575
- })]
7576
- }),
7577
- (() => {
7578
- let index = 0;
7579
- return visibleGroups.map((group, gi) => {
7580
- const rows = group.options.map((option) => {
7581
- const i = index++;
7582
- return /* @__PURE__ */ jsx(SelectOption, {
7583
- option,
7584
- selected: isSelected(option),
7585
- onSelect: () => selectItem(optionToItem(option)),
7586
- navProps: nav.itemProps(i)
7587
- }, option.value ?? i);
7588
- });
7589
- return group.label === void 0 ? rows : /* @__PURE__ */ jsxs("div", {
7590
- className: "sbk-select__group",
7591
- children: [/* @__PURE__ */ jsx("div", {
7592
- className: "sbk-select__group-label",
7593
- children: group.label
7594
- }), rows]
7595
- }, gi);
7596
- });
7597
- })(),
7598
- remote && loading && /* @__PURE__ */ jsx("div", {
7599
- className: "sbk-select__status",
7600
- children: "Loading…"
7601
- }),
7602
- remote && !loading && remoteGroups !== void 0 && flatOptions.length === 0 && /* @__PURE__ */ jsx("div", {
7603
- className: "sbk-select__status",
7604
- children: "No results"
7605
- }),
7606
- showSearchOnly && items.length > 0 && multi && /* @__PURE__ */ jsx("div", {
7607
- className: "sbk-select__group-label",
7608
- children: "Selected"
7609
- }),
7610
- showSearchOnly && multi && items.map((item) => /* @__PURE__ */ jsx("div", {
7611
- className: "sbk-select__option sbk-select__option--selected",
7612
- children: item.label || resolveLabel(item.id)
7613
- }, item.id))
7614
- ]
7615
- })
7616
- }),
8168
+ menu,
7617
8169
  dialog
7618
8170
  ]
7619
8171
  });
@@ -7715,28 +8267,47 @@ function TextInput({ element, blockId }) {
7715
8267
  }
7716
8268
  //#endregion
7717
8269
  //#region src/elements/TimePicker.tsx
7718
- /** Formats `HH:mm` the way Slack's closed timepicker shows it, e.g. "1:37 PM". */
8270
+ /** Formats `HH:mm` the way Slack's timepicker shows it, e.g. "1:37 PM". */
7719
8271
  function formatTime(time) {
7720
8272
  const [h = 0, m = 0] = time.split(":").map(Number);
7721
8273
  const period = h >= 12 ? "PM" : "AM";
7722
8274
  return `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, "0")} ${period}`;
7723
8275
  }
7724
- /** Every 30 minutes, as Slack's timepicker list offers. */
7725
- function timeOptions() {
7726
- const times = [];
7727
- for (let h = 0; h < 24; h++) for (const m of [0, 30]) times.push(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
7728
- return times;
8276
+ /** Every hour of the day, as Slack's timepicker list offers. */
8277
+ const HOURS = Array.from({ length: 24 }, (_, h) => `${String(h).padStart(2, "0")}:00`);
8278
+ /**
8279
+ * Reads a typed time as `HH:mm`: "2:15 PM", "2:15pm", "2pm", "14:15" or "9". Without AM or PM the
8280
+ * hour is read on a 24-hour clock. Slack accepts typed times too; whether it keeps one off the hour
8281
+ * (2:15 PM) or rounds it hasn't been verified, so the minutes are kept as typed.
8282
+ */
8283
+ function parseTime(input) {
8284
+ const match = /^(\d{1,2})(?::?(\d{2}))?\s*([ap])\.?m?\.?$|^(\d{1,2})(?::(\d{2}))?$/i.exec(input.trim());
8285
+ if (!match) return void 0;
8286
+ const meridiem = match[3]?.toLowerCase();
8287
+ let hour = Number(match[1] ?? match[4]);
8288
+ const minute = Number(match[2] ?? match[5] ?? 0);
8289
+ if (minute > 59) return void 0;
8290
+ if (meridiem) {
8291
+ if (hour < 1 || hour > 12) return void 0;
8292
+ hour = hour % 12 + (meridiem === "p" ? 12 : 0);
8293
+ } else if (hour > 23) return;
8294
+ return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
8295
+ }
8296
+ const squash = (text) => text.toLowerCase().replace(/\s+/g, "");
8297
+ /** The rows for a query: the hours whose label starts with it, after a typed time the list lacks. */
8298
+ function timesFor(query) {
8299
+ if (!query.trim()) return HOURS;
8300
+ const typed = parseTime(query);
8301
+ const hours = HOURS.filter((t) => squash(formatTime(t)).startsWith(squash(query)));
8302
+ return typed && !hours.includes(typed) && !HOURS.includes(typed) ? [typed, ...hours] : hours;
7729
8303
  }
7730
8304
  function TimePicker({ element, blockId }) {
7731
8305
  const { setValue, dispatch } = useBlockKit();
7732
8306
  const { ask, dialog } = useConfirm(element.confirm);
7733
8307
  const [time, setTime] = useState(element.initial_time);
7734
- const [open, setOpen] = useState(false);
7735
8308
  const rootRef = useRef(null);
7736
- const triggerRef = useRef(null);
7737
- useFocusOnLoad(element, triggerRef);
7738
- const invalid = useInvalidProps();
7739
8309
  const listRef = useRef(null);
8310
+ const invalid = useInvalidProps();
7740
8311
  const actionId = element.action_id ?? "";
7741
8312
  useEffect(() => {
7742
8313
  if (time) setValue(blockId, actionId, {
@@ -7745,9 +8316,9 @@ function TimePicker({ element, blockId }) {
7745
8316
  });
7746
8317
  }, []);
7747
8318
  async function pick(next) {
8319
+ combo.setOpen(false);
7748
8320
  if (!await ask()) return;
7749
8321
  setTime(next);
7750
- setOpen(false);
7751
8322
  setValue(blockId, actionId, {
7752
8323
  type: "timepicker",
7753
8324
  selected_time: next
@@ -7760,57 +8331,68 @@ function TimePicker({ element, blockId }) {
7760
8331
  ...element.initial_time !== void 0 ? { initial_time: element.initial_time } : {}
7761
8332
  });
7762
8333
  }
7763
- const times = timeOptions();
7764
- const nav = useMenuNavigation({
7765
- open,
8334
+ const [query, setQuery] = useState("");
8335
+ const times = timesFor(query);
8336
+ const combo = useCombobox({
8337
+ query,
8338
+ onQueryChange: setQuery,
7766
8339
  count: times.length,
7767
8340
  initialIndex: time ? times.indexOf(time) : -1,
7768
8341
  onChoose: (i) => {
7769
8342
  const t = times[i];
7770
8343
  if (t) pick(t);
7771
8344
  },
7772
- onClose: () => {
7773
- setOpen(false);
7774
- triggerRef.current?.focus();
8345
+ onSubmitQuery: (typed) => {
8346
+ const t = parseTime(typed);
8347
+ if (t) pick(t);
7775
8348
  },
7776
- onOpen: () => setOpen(true),
7777
8349
  listRef
7778
8350
  });
8351
+ useFocusOnLoad(element, combo.inputRef);
8352
+ const display = time ? formatTime(time) : void 0;
8353
+ const input = combo.inputProps(display, element.placeholder?.text ?? "Select time");
8354
+ const overlay = display !== void 0 && !combo.open;
7779
8355
  return /* @__PURE__ */ jsxs("div", {
7780
8356
  className: "sbk-timepicker",
7781
8357
  ref: rootRef,
7782
- onKeyDown: nav.onKeyDown,
7783
8358
  children: [
7784
- /* @__PURE__ */ jsxs("button", {
7785
- ref: triggerRef,
7786
- type: "button",
8359
+ /* @__PURE__ */ jsxs("label", {
7787
8360
  className: "sbk-timepicker__control",
7788
- onClick: () => setOpen((o) => !o),
7789
- "aria-haspopup": "listbox",
7790
- "aria-expanded": open,
7791
- ...invalid,
7792
8361
  children: [
7793
8362
  /* @__PURE__ */ jsx(ClockIcon, { className: "sbk-timepicker__icon" }),
7794
- /* @__PURE__ */ jsx("span", {
7795
- className: time ? "sbk-timepicker__value" : "sbk-timepicker__placeholder",
7796
- children: time ? formatTime(time) : element.placeholder?.text ?? "Select time"
8363
+ /* @__PURE__ */ jsx("input", {
8364
+ ...input,
8365
+ ...invalid,
8366
+ className: `sbk-timepicker__input${overlay ? " sbk-timepicker__input--behind" : ""}`
7797
8367
  }),
7798
- /* @__PURE__ */ jsx(ChevronDownIcon, { className: "sbk-timepicker__chevron" })
8368
+ /* @__PURE__ */ jsx(ChevronDownIcon, { className: "sbk-timepicker__chevron" }),
8369
+ overlay && /* @__PURE__ */ jsx("span", {
8370
+ className: "sbk-timepicker__content",
8371
+ "aria-hidden": "true",
8372
+ children: /* @__PURE__ */ jsx("span", {
8373
+ className: "sbk-timepicker__content-text",
8374
+ children: display
8375
+ })
8376
+ })
7799
8377
  ]
7800
8378
  }),
7801
- open && /* @__PURE__ */ jsx(Popover, {
8379
+ combo.open && /* @__PURE__ */ jsx(Popover, {
7802
8380
  anchorRef: rootRef,
7803
- onDismiss: () => setOpen(false),
8381
+ onDismiss: () => combo.setOpen(false),
8382
+ offsetX: -12,
8383
+ gap: -4,
7804
8384
  children: /* @__PURE__ */ jsx("div", {
7805
8385
  className: "sbk-timepicker__menu",
7806
8386
  role: "listbox",
8387
+ id: combo.listId,
7807
8388
  ref: listRef,
7808
8389
  children: times.map((t, i) => /* @__PURE__ */ jsx("div", {
7809
8390
  role: "option",
7810
8391
  "aria-selected": t === time,
7811
8392
  className: `sbk-timepicker__option${t === time ? " sbk-timepicker__option--selected" : ""}`,
8393
+ onMouseDown: (e) => e.preventDefault(),
7812
8394
  onClick: () => pick(t),
7813
- ...nav.itemProps(i),
8395
+ ...combo.optionProps(i),
7814
8396
  children: formatTime(t)
7815
8397
  }, t))
7816
8398
  })
@@ -9663,15 +10245,18 @@ function Input({ block, blockId }) {
9663
10245
  className: "sbk-input__element",
9664
10246
  children: /* @__PURE__ */ jsx(InputBlockContext.Provider, {
9665
10247
  value: true,
9666
- children: /* @__PURE__ */ jsx(InputErrorContext.Provider, {
9667
- value: error ? errorId : void 0,
9668
- children: block.dispatch_action === true ? /* @__PURE__ */ jsx(Element, {
9669
- element,
9670
- blockId
9671
- }) : /* @__PURE__ */ jsx(SuppressActions, { children: /* @__PURE__ */ jsx(Element, {
9672
- element,
9673
- blockId
9674
- }) })
10248
+ children: /* @__PURE__ */ jsx(InputOptionalContext.Provider, {
10249
+ value: block.optional === true,
10250
+ children: /* @__PURE__ */ jsx(InputErrorContext.Provider, {
10251
+ value: error ? errorId : void 0,
10252
+ children: block.dispatch_action === true ? /* @__PURE__ */ jsx(Element, {
10253
+ element,
10254
+ blockId
10255
+ }) : /* @__PURE__ */ jsx(SuppressActions, { children: /* @__PURE__ */ jsx(Element, {
10256
+ element,
10257
+ blockId
10258
+ }) })
10259
+ })
9675
10260
  })
9676
10261
  })
9677
10262
  }),
@@ -10110,6 +10695,7 @@ function TaskRow({ task }) {
10110
10695
  const expandable = Boolean(task.details || task.output || sources.length > 0);
10111
10696
  return /* @__PURE__ */ jsxs("li", {
10112
10697
  className: "sbk-plan__task",
10698
+ tabIndex: 0,
10113
10699
  children: [/* @__PURE__ */ jsxs("span", {
10114
10700
  className: "sbk-plan__step",
10115
10701
  "aria-hidden": "true",
@@ -10560,9 +11146,12 @@ function Section({ block, blockId }) {
10560
11146
  })]
10561
11147
  }), accessory && /* @__PURE__ */ jsx("div", {
10562
11148
  className: `sbk-section__accessory sbk-section__accessory--${accessory.type}`,
10563
- children: /* @__PURE__ */ jsx(Element, {
10564
- element: accessory,
10565
- blockId
11149
+ children: /* @__PURE__ */ jsx(SectionAccessoryContext.Provider, {
11150
+ value: true,
11151
+ children: /* @__PURE__ */ jsx(Element, {
11152
+ element: accessory,
11153
+ blockId
11154
+ })
10566
11155
  })
10567
11156
  })]
10568
11157
  });