@nkootstra/block-kit 0.3.4 → 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
@@ -31,7 +31,8 @@ const DEFAULT_IDENTITY = {
31
31
  apiAppId: "A00000000",
32
32
  token: "verification-token",
33
33
  triggerId: "000000000000.000000000000.deadbeefdeadbeefdeadbeefdeadbeef",
34
- responseUrl: "https://hooks.slack.com/actions/T00000000/0000000000000/deadbeefdeadbeefdeadbeefdeadbeef"
34
+ responseUrl: "https://hooks.slack.com/actions/T00000000/0000000000000/deadbeefdeadbeefdeadbeefdeadbeef",
35
+ isEnterpriseInstall: false
35
36
  };
36
37
  function resolveIdentity(identity) {
37
38
  return {
@@ -49,12 +50,12 @@ function toViewOutput(view, state, identity) {
49
50
  bot_id: "B00000000",
50
51
  callback_id: view.callback_id ?? "",
51
52
  type: view.type,
52
- title: view.title ?? null,
53
- close: view.close ?? null,
54
- submit: view.submit ?? null,
55
- 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),
56
57
  private_metadata: view.private_metadata ?? "",
57
- state: { values: state },
58
+ state: { values: normalizeState(state) },
58
59
  hash: view.hash ?? "0.0000000000",
59
60
  clear_on_close: view.clear_on_close ?? false,
60
61
  notify_on_close: view.notify_on_close ?? false,
@@ -63,12 +64,91 @@ function toViewOutput(view, state, identity) {
63
64
  external_id: view.external_id ?? ""
64
65
  };
65
66
  }
67
+ /**
68
+ * A text object as Slack echoes it back: `plain_text` gains `emoji: true` and `mrkdwn` gains
69
+ * `verbatim: false` unless the app set them.
70
+ */
71
+ function normalizeText(text) {
72
+ if (typeof text !== "object" || text === null) return text;
73
+ const object = text;
74
+ if (object.type === "plain_text") return {
75
+ ...text,
76
+ emoji: object.emoji ?? true
77
+ };
78
+ if (object.type === "mrkdwn") return {
79
+ ...text,
80
+ verbatim: object.verbatim ?? false
81
+ };
82
+ return text;
83
+ }
84
+ function normalizeOption(option) {
85
+ if (typeof option !== "object" || option === null) return option;
86
+ const { text, description } = option;
87
+ return {
88
+ ...option,
89
+ ...text === void 0 ? {} : { text: normalizeText(text) },
90
+ ...description === void 0 ? {} : { description: normalizeText(description) }
91
+ };
92
+ }
93
+ /** An action or state entry with every text object it echoes normalized like Slack's. */
94
+ function normalizeEchoes(entry) {
95
+ const out = { ...entry };
96
+ if (out.text !== void 0) out.text = normalizeText(out.text);
97
+ if (out.placeholder !== void 0) out.placeholder = normalizeText(out.placeholder);
98
+ if (out.selected_option) out.selected_option = normalizeOption(out.selected_option);
99
+ if (Array.isArray(out.selected_options)) out.selected_options = out.selected_options.map(normalizeOption);
100
+ if (typeof out.confirm === "object" && out.confirm !== null) {
101
+ const confirm = { ...out.confirm };
102
+ for (const key of [
103
+ "title",
104
+ "text",
105
+ "confirm",
106
+ "deny"
107
+ ]) if (confirm[key] !== void 0) confirm[key] = normalizeText(confirm[key]);
108
+ out.confirm = confirm;
109
+ }
110
+ return out;
111
+ }
112
+ function normalizeState(state) {
113
+ return Object.fromEntries(Object.entries(state).map(([blockId, elements]) => [blockId, Object.fromEntries(Object.entries(elements).map(([actionId, value]) => [actionId, normalizeEchoes(value)]))]));
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
+ }
66
144
  function buildBlockActionsPayload({ action, state, container, identity }) {
67
145
  const id = resolveIdentity(identity);
146
+ state = normalizeState(state);
68
147
  const base = {
69
148
  type: "block_actions",
70
- actions: [action],
149
+ actions: [normalizeEchoes(action)],
71
150
  team: id.team,
151
+ ...enterpriseFields(id),
72
152
  user: id.user,
73
153
  token: id.token,
74
154
  response_url: id.responseUrl,
@@ -108,6 +188,7 @@ function buildViewSubmissionPayload({ view, state, identity, responseUrls }) {
108
188
  return {
109
189
  type: "view_submission",
110
190
  team: id.team,
191
+ ...enterpriseFields(id),
111
192
  user: {
112
193
  id: id.user.id,
113
194
  name: id.user.name ?? id.user.username ?? "",
@@ -126,6 +207,7 @@ function buildViewClosedPayload({ view, state, identity, isCleared }) {
126
207
  return {
127
208
  type: "view_closed",
128
209
  team: id.team,
210
+ ...enterpriseFields(id),
129
211
  user: {
130
212
  id: id.user.id,
131
213
  name: id.user.name ?? id.user.username ?? "",
@@ -148,7 +230,8 @@ function buildBlockSuggestionPayload({ actionId, blockId, value, container, stat
148
230
  action_id: actionId,
149
231
  block_id: blockId,
150
232
  value,
151
- team: id.team
233
+ team: id.team,
234
+ ...enterpriseFields(id)
152
235
  };
153
236
  if (container.type === "message") return {
154
237
  ...base,
@@ -985,10 +1068,10 @@ function BlockKitProvider(props) {
985
1068
  containerRef.current = container;
986
1069
  }, []);
987
1070
  const dispatch = useCallback((action, scope) => {
988
- const full = {
1071
+ const full = normalizeEchoes({
989
1072
  ...action,
990
1073
  action_ts: action.action_ts ?? actionTs()
991
- };
1074
+ });
992
1075
  const message = scope?.message;
993
1076
  onAction?.(full, {
994
1077
  state: stateRef.current,
@@ -5927,33 +6010,89 @@ function Text({ text, emojiSize = 22 }) {
5927
6010
  }
5928
6011
  //#endregion
5929
6012
  //#region src/confirm/ConfirmDialog.tsx
5930
- /** 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
+ */
5931
6022
  function ConfirmDialog({ confirm, onConfirm, onDeny }) {
5932
6023
  const { theme } = useBlockKit();
5933
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
+ }
5934
6054
  const node = /* @__PURE__ */ jsx("div", {
5935
6055
  className: "sbk-confirm__overlay",
5936
6056
  role: "presentation",
5937
6057
  "data-theme": theme,
5938
6058
  onClick: onDeny,
5939
6059
  children: /* @__PURE__ */ jsxs("div", {
6060
+ ref: dialogRef,
5940
6061
  className: "sbk-confirm",
5941
6062
  role: "alertdialog",
5942
6063
  "aria-modal": "true",
5943
- "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,
5944
6068
  onClick: (event) => event.stopPropagation(),
5945
6069
  children: [
5946
- confirm.title && /* @__PURE__ */ jsx("h2", {
5947
- className: "sbk-confirm__title",
5948
- 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
+ })]
5949
6086
  }),
5950
6087
  /* @__PURE__ */ jsx("div", {
6088
+ id: textId,
5951
6089
  className: "sbk-confirm__text",
5952
6090
  children: /* @__PURE__ */ jsx(Text, { text: confirm.text })
5953
6091
  }),
5954
6092
  /* @__PURE__ */ jsxs("div", {
5955
6093
  className: "sbk-confirm__actions",
5956
6094
  children: [/* @__PURE__ */ jsx("button", {
6095
+ ref: denyRef,
5957
6096
  type: "button",
5958
6097
  className: "sbk-confirm__button sbk-confirm__button--deny",
5959
6098
  onClick: onDeny,
@@ -6017,7 +6156,8 @@ function Button({ element, blockId }) {
6017
6156
  },
6018
6157
  ...element.value !== void 0 ? { value: element.value } : {},
6019
6158
  ...element.url !== void 0 ? { url: element.url } : {},
6020
- ...element.style !== void 0 ? { style: element.style } : {}
6159
+ ...element.style !== void 0 ? { style: element.style } : {},
6160
+ ...element.confirm ? { confirm: element.confirm } : {}
6021
6161
  });
6022
6162
  if (element.url) window.open(element.url, "_blank", "noopener,noreferrer");
6023
6163
  };
@@ -6125,31 +6265,107 @@ function todayISO() {
6125
6265
  const now = /* @__PURE__ */ new Date();
6126
6266
  return toISO(now.getFullYear(), now.getMonth(), now.getDate());
6127
6267
  }
6128
- function Caret({ direction }) {
6129
- return /* @__PURE__ */ jsx("svg", {
6130
- viewBox: "0 0 20 20",
6131
- width: "20",
6132
- height: "20",
6133
- "aria-hidden": "true",
6134
- children: /* @__PURE__ */ jsx("path", {
6135
- fill: "currentColor",
6136
- fillRule: "evenodd",
6137
- 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",
6138
- 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
+ })
6139
6293
  })
6140
6294
  });
6141
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
+ };
6142
6309
  /** A month grid matching Slack's `c-date_picker_calendar`: a bordered 7-column grid of 44×42
6143
- * 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. */
6144
6313
  function Calendar({ value, onSelect, onClear }) {
6145
6314
  const initial = value ? /* @__PURE__ */ new Date(`${value}T00:00:00Z`) : /* @__PURE__ */ new Date();
6146
6315
  const [year, setYear] = useState(initial.getUTCFullYear());
6147
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);
6148
6350
  const first = new Date(Date.UTC(year, month, 1));
6149
6351
  const startWeekday = first.getUTCDay();
6150
6352
  const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
6151
6353
  const cells = [...Array(startWeekday).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1)];
6152
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
+ }
6153
6369
  function go(delta) {
6154
6370
  const d = new Date(Date.UTC(year, month + delta, 1));
6155
6371
  setYear(d.getUTCFullYear());
@@ -6160,50 +6376,59 @@ function Calendar({ value, onSelect, onClear }) {
6160
6376
  year: "numeric",
6161
6377
  timeZone: "UTC"
6162
6378
  });
6163
- return /* @__PURE__ */ jsxs("div", {
6379
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
6164
6380
  className: "sbk-calendar",
6165
6381
  children: [
6166
6382
  /* @__PURE__ */ jsxs("div", {
6167
6383
  className: "sbk-calendar__header",
6168
6384
  children: [
6169
- /* @__PURE__ */ jsx("button", {
6170
- type: "button",
6171
- className: "sbk-calendar__nav",
6172
- onClick: () => go(-1),
6173
- "aria-label": "Previous month",
6174
- 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)
6175
6394
  }),
6176
6395
  /* @__PURE__ */ jsx("span", {
6177
6396
  className: "sbk-calendar__label",
6178
6397
  children: monthLabel
6179
6398
  }),
6180
- /* @__PURE__ */ jsx("button", {
6181
- type: "button",
6182
- className: "sbk-calendar__nav",
6183
- onClick: () => go(1),
6184
- "aria-label": "Next month",
6185
- 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)
6186
6408
  })
6187
6409
  ]
6188
6410
  }),
6189
6411
  /* @__PURE__ */ jsx("div", {
6190
6412
  className: "sbk-calendar__weekdays",
6191
6413
  children: [
6192
- "Sun",
6193
- "Mon",
6194
- "Tue",
6195
- "Wed",
6196
- "Thu",
6197
- "Fri",
6198
- "Sat"
6414
+ "Su",
6415
+ "Mo",
6416
+ "Tu",
6417
+ "We",
6418
+ "Th",
6419
+ "Fr",
6420
+ "Sa"
6199
6421
  ].map((w) => /* @__PURE__ */ jsx("span", { children: w }, w))
6200
6422
  }),
6201
6423
  /* @__PURE__ */ jsx("div", {
6202
- className: "sbk-calendar__grid",
6424
+ className: `sbk-calendar__grid${keyboard ? " sbk-calendar__grid--keyboard" : ""}`,
6425
+ ref: gridRef,
6426
+ onKeyDown: onGridKeyDown,
6427
+ onPointerDown: () => setKeyboard(false),
6203
6428
  children: cells.map((day, i) => {
6204
6429
  if (day === null) return /* @__PURE__ */ jsx("span", { className: "sbk-calendar__cell sbk-calendar__cell--empty" }, i);
6205
6430
  const iso = toISO(year, month, day);
6206
- const classes = ["sbk-calendar__cell"];
6431
+ const classes = ["sbk-calendar__cell", ...corners(day).map((c) => `sbk-calendar__cell--${c}`)];
6207
6432
  if (iso === value) classes.push("sbk-calendar__cell--selected");
6208
6433
  if (iso === today) classes.push("sbk-calendar__cell--today");
6209
6434
  return /* @__PURE__ */ jsx("button", {
@@ -6211,22 +6436,24 @@ function Calendar({ value, onSelect, onClear }) {
6211
6436
  className: classes.join(" "),
6212
6437
  "aria-pressed": iso === value,
6213
6438
  "aria-current": iso === today ? "date" : void 0,
6439
+ "data-date": iso,
6440
+ tabIndex: iso === tabStop ? 0 : -1,
6441
+ onFocus: () => setFocused(iso),
6214
6442
  onClick: () => onSelect(iso),
6215
6443
  children: day
6216
6444
  }, iso);
6217
6445
  })
6218
- }),
6219
- onClear && value && /* @__PURE__ */ jsx("div", {
6220
- className: "sbk-calendar__footer",
6221
- children: /* @__PURE__ */ jsx("button", {
6222
- type: "button",
6223
- className: "sbk-calendar__clear",
6224
- onClick: onClear,
6225
- children: "Clear selection"
6226
- })
6227
6446
  })
6228
6447
  ]
6229
- });
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
+ })] });
6230
6457
  }
6231
6458
  //#endregion
6232
6459
  //#region src/elements/dateFormat.ts
@@ -6325,6 +6552,11 @@ const InputBlockContext = createContext(false);
6325
6552
  function useInInputBlock() {
6326
6553
  return useContext(InputBlockContext);
6327
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
+ }
6328
6560
  /** The id of the error shown under an `input` block's element, while it has one. */
6329
6561
  const InputErrorContext = createContext(void 0);
6330
6562
  /**
@@ -6342,14 +6574,18 @@ function useInvalidProps() {
6342
6574
  //#region src/elements/Popover.tsx
6343
6575
  /** Space between the anchor and the popover, as in Slack's menus. */
6344
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;
6345
6579
  /**
6346
6580
  * The layer a menu or calendar opens in. Slack mounts its popovers at the top of the page, so a
6347
6581
  * message list, a modal body or a carousel never clips them; this portals to `<body>` the same way
6348
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.
6349
6585
  * The layer is as wide as the anchor, so a child sized `width: 100%` matches the control it opened
6350
6586
  * from.
6351
6587
  */
6352
- function Popover({ anchorRef, onDismiss, gap = DEFAULT_GAP, children }) {
6588
+ function Popover({ anchorRef, onDismiss, gap = DEFAULT_GAP, offsetX = 0, onPlaced, children }) {
6353
6589
  const { theme } = useBlockKit();
6354
6590
  const layerRef = useRef(null);
6355
6591
  const [position, setPosition] = useState(null);
@@ -6370,9 +6606,19 @@ function Popover({ anchorRef, onDismiss, gap = DEFAULT_GAP, children }) {
6370
6606
  const fitsAbove = above >= 0;
6371
6607
  let top = !fitsBelow && fitsAbove ? above : below;
6372
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
+ }
6373
6619
  setPosition({
6374
6620
  top,
6375
- left: a.left,
6621
+ left,
6376
6622
  width: a.width
6377
6623
  });
6378
6624
  }
@@ -6387,7 +6633,18 @@ function Popover({ anchorRef, onDismiss, gap = DEFAULT_GAP, children }) {
6387
6633
  window.removeEventListener("resize", place);
6388
6634
  resize?.disconnect();
6389
6635
  };
6390
- }, [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]);
6391
6648
  useEffect(() => {
6392
6649
  function onDocDown(e) {
6393
6650
  const target = e.target;
@@ -6427,14 +6684,27 @@ function formatDate$1(date, long) {
6427
6684
  function DatePicker({ element, blockId }) {
6428
6685
  const { setValue, dispatch } = useBlockKit();
6429
6686
  const inInputBlock = useInInputBlock();
6687
+ const clearable = useInOptionalInput();
6430
6688
  const { ask, dialog } = useConfirm(element.confirm);
6431
6689
  const [date, setDate] = useState(element.initial_date);
6432
6690
  const [open, setOpen] = useState(false);
6433
6691
  const rootRef = useRef(null);
6434
6692
  const inputRef = useFocusOnLoad(element);
6435
6693
  const invalid = useInvalidProps();
6436
- 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);
6437
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
+ }
6438
6708
  useEffect(() => {
6439
6709
  if (date) setValue(blockId, actionId, {
6440
6710
  type: "datepicker",
@@ -6444,7 +6714,7 @@ function DatePicker({ element, blockId }) {
6444
6714
  async function pick(next) {
6445
6715
  if (!await ask()) return;
6446
6716
  setDate(next);
6447
- setOpen(false);
6717
+ close();
6448
6718
  setValue(blockId, actionId, {
6449
6719
  type: "datepicker",
6450
6720
  selected_date: next
@@ -6453,13 +6723,14 @@ function DatePicker({ element, blockId }) {
6453
6723
  type: "datepicker",
6454
6724
  action_id: actionId,
6455
6725
  block_id: blockId,
6456
- selected_date: next
6726
+ selected_date: next,
6727
+ ...element.initial_date !== void 0 ? { initial_date: element.initial_date } : {}
6457
6728
  });
6458
6729
  }
6459
6730
  async function clear() {
6460
6731
  if (!await ask()) return;
6461
6732
  setDate(void 0);
6462
- setOpen(false);
6733
+ close();
6463
6734
  setValue(blockId, actionId, {
6464
6735
  type: "datepicker",
6465
6736
  selected_date: null
@@ -6468,7 +6739,8 @@ function DatePicker({ element, blockId }) {
6468
6739
  type: "datepicker",
6469
6740
  action_id: actionId,
6470
6741
  block_id: blockId,
6471
- selected_date: null
6742
+ selected_date: null,
6743
+ ...element.initial_date !== void 0 ? { initial_date: element.initial_date } : {}
6472
6744
  });
6473
6745
  }
6474
6746
  return /* @__PURE__ */ jsxs("div", {
@@ -6489,12 +6761,27 @@ function DatePicker({ element, blockId }) {
6489
6761
  ...invalid,
6490
6762
  ref: inputRef,
6491
6763
  onFocus: () => {
6492
- if (loadFocus.current) loadFocus.current = false;
6764
+ if (quietFocus.current) quietFocus.current = false;
6493
6765
  else setOpen(true);
6494
6766
  },
6495
- 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
+ },
6496
6775
  onKeyDown: (e) => {
6497
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
+ }
6498
6785
  }
6499
6786
  }),
6500
6787
  /* @__PURE__ */ jsx(ChevronDownIcon, { className: "sbk-datepicker__chevron" })
@@ -6502,13 +6789,26 @@ function DatePicker({ element, blockId }) {
6502
6789
  }),
6503
6790
  open && /* @__PURE__ */ jsx(Popover, {
6504
6791
  anchorRef: rootRef,
6792
+ gap: -4,
6505
6793
  onDismiss: () => setOpen(false),
6794
+ onPlaced: () => {
6795
+ if (focusCalendar.current) focusDay();
6796
+ focusCalendar.current = false;
6797
+ },
6506
6798
  children: /* @__PURE__ */ jsx("div", {
6507
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
+ },
6508
6808
  children: /* @__PURE__ */ jsx(Calendar, {
6509
6809
  value: date,
6510
6810
  onSelect: pick,
6511
- onClear: clear
6811
+ onClear: clearable ? clear : void 0
6512
6812
  })
6513
6813
  })
6514
6814
  }),
@@ -6884,9 +7184,11 @@ function Overflow({ element, blockId }) {
6884
7184
  const triggerRef = useRef(null);
6885
7185
  const listRef = useRef(null);
6886
7186
  const actionId = element.action_id ?? "";
7187
+ const idPrefix = useId();
6887
7188
  async function choose(option) {
6888
7189
  if (!await ask()) return;
6889
7190
  setOpen(false);
7191
+ triggerRef.current?.focus();
6890
7192
  dispatch({
6891
7193
  type: "overflow",
6892
7194
  action_id: actionId,
@@ -6927,11 +7229,15 @@ function Overflow({ element, blockId }) {
6927
7229
  open && /* @__PURE__ */ jsx(Popover, {
6928
7230
  anchorRef: rootRef,
6929
7231
  onDismiss: () => setOpen(false),
7232
+ onPlaced: () => listRef.current?.focus(),
6930
7233
  children: /* @__PURE__ */ jsx("div", {
6931
7234
  className: "sbk-overflow__menu",
6932
7235
  role: "menu",
6933
7236
  ref: listRef,
7237
+ tabIndex: -1,
7238
+ "aria-activedescendant": nav.active >= 0 ? `${idPrefix}-${nav.active}` : void 0,
6934
7239
  children: element.options.map((option, i) => /* @__PURE__ */ jsx("div", {
7240
+ id: `${idPrefix}-${i}`,
6935
7241
  role: "menuitem",
6936
7242
  className: "sbk-overflow__option",
6937
7243
  onClick: () => choose(option),
@@ -7222,6 +7528,236 @@ function RichTextInput({ element, blockId }) {
7222
7528
  });
7223
7529
  }
7224
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
7225
7761
  //#region src/elements/Select.tsx
7226
7762
  function parseType(type) {
7227
7763
  const multi = type.startsWith("multi_");
@@ -7291,7 +7827,6 @@ function Select({ element, blockId }) {
7291
7827
  const [query, setQuery] = useState("");
7292
7828
  const rootRef = useRef(null);
7293
7829
  const triggerRef = useRef(null);
7294
- useFocusOnLoad(element, triggerRef);
7295
7830
  const invalid = useInvalidProps();
7296
7831
  const listRef = useRef(null);
7297
7832
  const remote = source === "external" && loadOptions !== void 0;
@@ -7378,7 +7913,8 @@ function Select({ element, blockId }) {
7378
7913
  type: element.type,
7379
7914
  action_id: actionId,
7380
7915
  block_id: blockId,
7381
- ...fields
7916
+ ...fields,
7917
+ ...element.placeholder ? { placeholder: element.placeholder } : {}
7382
7918
  });
7383
7919
  }
7384
7920
  function buildStateFor(next) {
@@ -7411,28 +7947,166 @@ function Select({ element, blockId }) {
7411
7947
  })) : [{ options: filteredOptions }];
7412
7948
  const flatOptions = visibleGroups.flatMap((g) => g.options);
7413
7949
  const isSelected = (option) => items.some((i) => i.id === (option.value ?? option.text.text));
7414
- 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({
7415
7955
  open,
7956
+ onOpenChange: setOpen,
7957
+ query,
7958
+ onQueryChange: setQuery,
7416
7959
  count: flatOptions.length,
7417
7960
  initialIndex: Math.max(0, flatOptions.findIndex(isSelected)),
7418
7961
  onChoose: (i) => {
7419
7962
  const option = flatOptions[i];
7420
7963
  if (option) selectItem(optionToItem(option));
7421
7964
  },
7422
- onClose: () => {
7423
- setOpen(false);
7424
- triggerRef.current?.focus();
7965
+ onSubmitQuery: (typed) => {
7966
+ if (showSearchOnly && !remote) selectItem({
7967
+ id: typed,
7968
+ label: resolveLabel(typed)
7969
+ });
7425
7970
  },
7426
- onOpen: () => setOpen(true),
7427
- listRef
7971
+ listRef,
7972
+ returnFocusRef: typeable ? void 0 : triggerRef
7428
7973
  });
7974
+ useFocusOnLoad(element, typeable ? combo.inputRef : triggerRef);
7429
7975
  const closedLabel = multi ? void 0 : items[0] ? items[0].label || resolveLabel(items[0].id) : void 0;
7430
7976
  const placeholder = element.placeholder?.text ?? "Select an item";
7431
- 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
+ }
7432
8106
  return /* @__PURE__ */ jsxs("div", {
7433
8107
  className: `sbk-select${multi ? " sbk-select--multi" : ""}${multi && items.length > 0 ? " sbk-select--chips" : ""}${sizeClass}`,
7434
8108
  ref: rootRef,
7435
- onKeyDown: nav.onKeyDown,
8109
+ onKeyDown: combo.onKeyDown,
7436
8110
  children: [
7437
8111
  multi && element.max_selected_items && /* @__PURE__ */ jsxs("p", {
7438
8112
  className: "sbk-select__max-info",
@@ -7491,70 +8165,7 @@ function Select({ element, blockId }) {
7491
8165
  children: placeholder
7492
8166
  }), /* @__PURE__ */ jsx(ChevronDownIcon, { className: "sbk-select__chevron" })]
7493
8167
  }),
7494
- open && /* @__PURE__ */ jsx(Popover, {
7495
- anchorRef: rootRef,
7496
- onDismiss: () => setOpen(false),
7497
- children: /* @__PURE__ */ jsxs("div", {
7498
- className: "sbk-select__menu",
7499
- role: "listbox",
7500
- ref: listRef,
7501
- children: [
7502
- (showSearchOnly || allOptions(element).length > 8) && /* @__PURE__ */ jsxs("div", {
7503
- className: "sbk-select__search",
7504
- children: [/* @__PURE__ */ jsx(SearchIcon$1, {}), /* @__PURE__ */ jsx("input", {
7505
- autoFocus: true,
7506
- className: "sbk-select__search-input",
7507
- placeholder: source === "static" || source === "external" ? "Search options" : `Search ${source}`,
7508
- value: query,
7509
- onChange: (e) => setQuery(e.target.value),
7510
- onKeyDown: (e) => {
7511
- if (e.key === "Enter" && query.trim() && showSearchOnly && !remote) selectItem({
7512
- id: query.trim(),
7513
- label: resolveLabel(query.trim())
7514
- });
7515
- }
7516
- })]
7517
- }),
7518
- (() => {
7519
- let index = 0;
7520
- return visibleGroups.map((group, gi) => {
7521
- const rows = group.options.map((option) => {
7522
- const i = index++;
7523
- return /* @__PURE__ */ jsx(SelectOption, {
7524
- option,
7525
- selected: isSelected(option),
7526
- onSelect: () => selectItem(optionToItem(option)),
7527
- navProps: nav.itemProps(i)
7528
- }, option.value ?? i);
7529
- });
7530
- return group.label === void 0 ? rows : /* @__PURE__ */ jsxs("div", {
7531
- className: "sbk-select__group",
7532
- children: [/* @__PURE__ */ jsx("div", {
7533
- className: "sbk-select__group-label",
7534
- children: group.label
7535
- }), rows]
7536
- }, gi);
7537
- });
7538
- })(),
7539
- remote && loading && /* @__PURE__ */ jsx("div", {
7540
- className: "sbk-select__status",
7541
- children: "Loading…"
7542
- }),
7543
- remote && !loading && remoteGroups !== void 0 && flatOptions.length === 0 && /* @__PURE__ */ jsx("div", {
7544
- className: "sbk-select__status",
7545
- children: "No results"
7546
- }),
7547
- showSearchOnly && items.length > 0 && multi && /* @__PURE__ */ jsx("div", {
7548
- className: "sbk-select__group-label",
7549
- children: "Selected"
7550
- }),
7551
- showSearchOnly && multi && items.map((item) => /* @__PURE__ */ jsx("div", {
7552
- className: "sbk-select__option sbk-select__option--selected",
7553
- children: item.label || resolveLabel(item.id)
7554
- }, item.id))
7555
- ]
7556
- })
7557
- }),
8168
+ menu,
7558
8169
  dialog
7559
8170
  ]
7560
8171
  });
@@ -7656,28 +8267,47 @@ function TextInput({ element, blockId }) {
7656
8267
  }
7657
8268
  //#endregion
7658
8269
  //#region src/elements/TimePicker.tsx
7659
- /** 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". */
7660
8271
  function formatTime(time) {
7661
8272
  const [h = 0, m = 0] = time.split(":").map(Number);
7662
8273
  const period = h >= 12 ? "PM" : "AM";
7663
8274
  return `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, "0")} ${period}`;
7664
8275
  }
7665
- /** Every 30 minutes, as Slack's timepicker list offers. */
7666
- function timeOptions() {
7667
- const times = [];
7668
- 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")}`);
7669
- 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;
7670
8303
  }
7671
8304
  function TimePicker({ element, blockId }) {
7672
8305
  const { setValue, dispatch } = useBlockKit();
7673
8306
  const { ask, dialog } = useConfirm(element.confirm);
7674
8307
  const [time, setTime] = useState(element.initial_time);
7675
- const [open, setOpen] = useState(false);
7676
8308
  const rootRef = useRef(null);
7677
- const triggerRef = useRef(null);
7678
- useFocusOnLoad(element, triggerRef);
7679
- const invalid = useInvalidProps();
7680
8309
  const listRef = useRef(null);
8310
+ const invalid = useInvalidProps();
7681
8311
  const actionId = element.action_id ?? "";
7682
8312
  useEffect(() => {
7683
8313
  if (time) setValue(blockId, actionId, {
@@ -7686,9 +8316,9 @@ function TimePicker({ element, blockId }) {
7686
8316
  });
7687
8317
  }, []);
7688
8318
  async function pick(next) {
8319
+ combo.setOpen(false);
7689
8320
  if (!await ask()) return;
7690
8321
  setTime(next);
7691
- setOpen(false);
7692
8322
  setValue(blockId, actionId, {
7693
8323
  type: "timepicker",
7694
8324
  selected_time: next
@@ -7697,60 +8327,72 @@ function TimePicker({ element, blockId }) {
7697
8327
  type: "timepicker",
7698
8328
  action_id: actionId,
7699
8329
  block_id: blockId,
7700
- selected_time: next
8330
+ selected_time: next,
8331
+ ...element.initial_time !== void 0 ? { initial_time: element.initial_time } : {}
7701
8332
  });
7702
8333
  }
7703
- const times = timeOptions();
7704
- const nav = useMenuNavigation({
7705
- open,
8334
+ const [query, setQuery] = useState("");
8335
+ const times = timesFor(query);
8336
+ const combo = useCombobox({
8337
+ query,
8338
+ onQueryChange: setQuery,
7706
8339
  count: times.length,
7707
8340
  initialIndex: time ? times.indexOf(time) : -1,
7708
8341
  onChoose: (i) => {
7709
8342
  const t = times[i];
7710
8343
  if (t) pick(t);
7711
8344
  },
7712
- onClose: () => {
7713
- setOpen(false);
7714
- triggerRef.current?.focus();
8345
+ onSubmitQuery: (typed) => {
8346
+ const t = parseTime(typed);
8347
+ if (t) pick(t);
7715
8348
  },
7716
- onOpen: () => setOpen(true),
7717
8349
  listRef
7718
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;
7719
8355
  return /* @__PURE__ */ jsxs("div", {
7720
8356
  className: "sbk-timepicker",
7721
8357
  ref: rootRef,
7722
- onKeyDown: nav.onKeyDown,
7723
8358
  children: [
7724
- /* @__PURE__ */ jsxs("button", {
7725
- ref: triggerRef,
7726
- type: "button",
8359
+ /* @__PURE__ */ jsxs("label", {
7727
8360
  className: "sbk-timepicker__control",
7728
- onClick: () => setOpen((o) => !o),
7729
- "aria-haspopup": "listbox",
7730
- "aria-expanded": open,
7731
- ...invalid,
7732
8361
  children: [
7733
8362
  /* @__PURE__ */ jsx(ClockIcon, { className: "sbk-timepicker__icon" }),
7734
- /* @__PURE__ */ jsx("span", {
7735
- className: time ? "sbk-timepicker__value" : "sbk-timepicker__placeholder",
7736
- 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" : ""}`
7737
8367
  }),
7738
- /* @__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
+ })
7739
8377
  ]
7740
8378
  }),
7741
- open && /* @__PURE__ */ jsx(Popover, {
8379
+ combo.open && /* @__PURE__ */ jsx(Popover, {
7742
8380
  anchorRef: rootRef,
7743
- onDismiss: () => setOpen(false),
8381
+ onDismiss: () => combo.setOpen(false),
8382
+ offsetX: -12,
8383
+ gap: -4,
7744
8384
  children: /* @__PURE__ */ jsx("div", {
7745
8385
  className: "sbk-timepicker__menu",
7746
8386
  role: "listbox",
8387
+ id: combo.listId,
7747
8388
  ref: listRef,
7748
8389
  children: times.map((t, i) => /* @__PURE__ */ jsx("div", {
7749
8390
  role: "option",
7750
8391
  "aria-selected": t === time,
7751
8392
  className: `sbk-timepicker__option${t === time ? " sbk-timepicker__option--selected" : ""}`,
8393
+ onMouseDown: (e) => e.preventDefault(),
7752
8394
  onClick: () => pick(t),
7753
- ...nav.itemProps(i),
8395
+ ...combo.optionProps(i),
7754
8396
  children: formatTime(t)
7755
8397
  }, t))
7756
8398
  })
@@ -9603,15 +10245,18 @@ function Input({ block, blockId }) {
9603
10245
  className: "sbk-input__element",
9604
10246
  children: /* @__PURE__ */ jsx(InputBlockContext.Provider, {
9605
10247
  value: true,
9606
- children: /* @__PURE__ */ jsx(InputErrorContext.Provider, {
9607
- value: error ? errorId : void 0,
9608
- children: block.dispatch_action === true ? /* @__PURE__ */ jsx(Element, {
9609
- element,
9610
- blockId
9611
- }) : /* @__PURE__ */ jsx(SuppressActions, { children: /* @__PURE__ */ jsx(Element, {
9612
- element,
9613
- blockId
9614
- }) })
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
+ })
9615
10260
  })
9616
10261
  })
9617
10262
  }),
@@ -10050,6 +10695,7 @@ function TaskRow({ task }) {
10050
10695
  const expandable = Boolean(task.details || task.output || sources.length > 0);
10051
10696
  return /* @__PURE__ */ jsxs("li", {
10052
10697
  className: "sbk-plan__task",
10698
+ tabIndex: 0,
10053
10699
  children: [/* @__PURE__ */ jsxs("span", {
10054
10700
  className: "sbk-plan__step",
10055
10701
  "aria-hidden": "true",
@@ -10500,9 +11146,12 @@ function Section({ block, blockId }) {
10500
11146
  })]
10501
11147
  }), accessory && /* @__PURE__ */ jsx("div", {
10502
11148
  className: `sbk-section__accessory sbk-section__accessory--${accessory.type}`,
10503
- children: /* @__PURE__ */ jsx(Element, {
10504
- element: accessory,
10505
- blockId
11149
+ children: /* @__PURE__ */ jsx(SectionAccessoryContext.Provider, {
11150
+ value: true,
11151
+ children: /* @__PURE__ */ jsx(Element, {
11152
+ element: accessory,
11153
+ blockId
11154
+ })
10506
11155
  })
10507
11156
  })]
10508
11157
  });