@botiverse/raft-ui 0.89.0 → 0.91.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/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [0.91.0](https://github.com/botiverse/raft-design/compare/v0.90.0...v0.91.0) (2026-11-10)
2
+
3
+ ### Fixed
4
+
5
+ - **`FitToolbarSearch` cuts a placeholder that doesn't fit at a word: "Search…", not "Search …"** ([FitToolbar › Behavior](https://raft-design.botiverse.build/components/fit-toolbar/#behavior)). It measures the placeholder in the input's own font against the field's room, again when the field changes size, a font arrives or the placeholder changes, and shows the longest prefix that ends at a word (between any two characters in Chinese or Japanese) and fits with "…" after it, trailing spaces trimmed. A placeholder that fits is shown whole. `placeholder:text-ellipsis` is gone from the input; typed text still ends in an ellipsis.
6
+ - Why: CSS `text-overflow: ellipsis` cuts after the space and showed "Search …", which reads as a typo (Jasper, 2026-11-10, #proj-raft-ui:f9756e85).
7
+ - Affects: a FitToolbarSearch whose placeholder is cut; its input's `placeholder` attribute is now the cut text. The accessible name is still the whole `aria-label`. Pages pass the whole placeholder as before.
8
+ - Docs: https://raft-design.botiverse.build/components/fit-toolbar/
9
+
10
+ ## [0.90.0](https://github.com/botiverse/raft-design/compare/v0.89.0...v0.90.0) (2026-11-10)
11
+
12
+ ### Changed
13
+
14
+ - **`FitToolbarSearch`'s least width is capped at 120px** ([FitToolbar › Behavior](https://raft-design.botiverse.build/components/fit-toolbar/#behavior)). `FIT_SEARCH_MAX_LEAST` is now 120 (was 160): the search icon, the field's side padding and "Search…", the same cap at every width. The measured least width is still the icon and the whole placeholder when that is smaller; a longer placeholder ends in an ellipsis.
15
+ - Why: slock's Members › Agents at 375px left "Agents ▾" under 160px and the search collapsed to its icon (Jasper, 2026-11-10, #proj-raft-ui:f9756e85).
16
+ - Affects: a search whose placeholder takes more than 120px ("Search tasks" takes about 140px) now keeps its field down to 120px with the placeholder cut, and the toolbar's earlier steps stop sooner. An explicit `minWidth` is unchanged and not capped.
17
+ - Docs: https://raft-design.botiverse.build/components/fit-toolbar/
18
+
1
19
  ## [0.89.0](https://github.com/botiverse/raft-design/compare/v0.88.0...v0.89.0) (2026-11-10)
2
20
 
3
21
  ### Changed
@@ -244,9 +244,13 @@ export declare function FitToolbarPicker({ trigger, icon, children, value, label
244
244
  * and its whole placeholder, or this, whichever is smaller. A longer
245
245
  * placeholder is cut with an ellipsis instead of collapsing the field to its
246
246
  * button, since it is only a hint and goes once the person types (Jasper,
247
- * 2026-11-10, #proj-raft-ui:f9756e85). An explicit `minWidth` isn't capped.
247
+ * 2026-11-10, #proj-raft-ui:f9756e85). 120px is the icon, the side padding
248
+ * and "Search…", the same at every width: at 160px, slock's Members › Agents
249
+ * at 375px left "Agents ▾" under 160px and the search collapsed (Jasper,
250
+ * 2026-11-10). A smaller measured width still wins; an explicit `minWidth`
251
+ * isn't capped.
248
252
  */
249
- export declare const FIT_SEARCH_MAX_LEAST = 160;
253
+ export declare const FIT_SEARCH_MAX_LEAST = 120;
250
254
  export type FitToolbarSearchProps = Omit<InputGroupInputProps, "className" | "value" | "defaultValue" | "onValueChange" | "aria-label" | "render"> & {
251
255
  /** Its accessible name ("Search tasks"): the field's and, once it is an icon, the button's, and the button's tooltip. */
252
256
  "aria-label": string;
@@ -260,7 +264,7 @@ export type FitToolbarSearchProps = Omit<InputGroupInputProps, "className" | "va
260
264
  * The least it may be, in px, before the toolbar gives way. By default it
261
265
  * is measured: what its search icon and its whole placeholder (or, with
262
266
  * none, its `aria-label`) take, in the field's own font, so the empty field
263
- * shows what it is for, up to `FIT_SEARCH_MAX_LEAST` (160px): a longer
267
+ * shows what it is for, up to `FIT_SEARCH_MAX_LEAST` (120px): a longer
264
268
  * placeholder ends in an ellipsis. A number given here isn't capped.
265
269
  */
266
270
  minWidth?: number;
@@ -281,12 +285,12 @@ export type FitToolbarSearchProps = Omit<InputGroupInputProps, "className" | "va
281
285
  /**
282
286
  * The search field of a list's toolbar: it fills the rest of its line and
283
287
  * keeps at least the room its icon and its whole placeholder take, up to
284
- * 160px (measured, or `minWidth`), so the toolbar's other steps come before
288
+ * 120px (measured, or `minWidth`), so the toolbar's other steps come before
285
289
  * it is squeezed. From `collapseOrder` on it is a search icon button at the
286
290
  * start of the line, which opens the field in the scope's place, More and the
287
291
  * primary action kept at the line's end; the
288
292
  * field closes once it is empty and loses focus, or on Esc. A search with
289
293
  * text stays open. Put it straight in the FitToolbar, between its groups.
290
294
  */
291
- export declare function FitToolbarSearch({ "aria-label": label, value, defaultValue, onValueChange, minWidth, collapseOrder, icon, clearLabel, className, style, onKeyDown, disabled, ref, ...props }: FitToolbarSearchProps): import("react").JSX.Element;
295
+ export declare function FitToolbarSearch({ "aria-label": label, value, defaultValue, onValueChange, minWidth, collapseOrder, icon, clearLabel, className, style, onKeyDown, disabled, placeholder: hint, ref, ...props }: FitToolbarSearchProps): import("react").JSX.Element;
292
296
  //#endregion
@@ -878,20 +878,92 @@ function coverScope(root, search) {
878
878
  * and its whole placeholder, or this, whichever is smaller. A longer
879
879
  * placeholder is cut with an ellipsis instead of collapsing the field to its
880
880
  * button, since it is only a hint and goes once the person types (Jasper,
881
- * 2026-11-10, #proj-raft-ui:f9756e85). An explicit `minWidth` isn't capped.
881
+ * 2026-11-10, #proj-raft-ui:f9756e85). 120px is the icon, the side padding
882
+ * and "Search…", the same at every width: at 160px, slock's Members › Agents
883
+ * at 375px left "Agents ▾" under 160px and the search collapsed (Jasper,
884
+ * 2026-11-10). A smaller measured width still wins; an explicit `minWidth`
885
+ * isn't capped.
882
886
  */
883
- const FIT_SEARCH_MAX_LEAST = 160;
887
+ const FIT_SEARCH_MAX_LEAST = 120;
888
+ const ELLIPSIS = "…";
889
+ /** Scripts written without spaces between words, which may be cut between any two characters. */
890
+ const NO_SPACES = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
891
+ /**
892
+ * Where a placeholder may be cut, as indices into it, shortest first: after
893
+ * a word followed by a space or punctuation (Intl.Segmenter's words, or the
894
+ * spaces without it), and between any two characters of a script written
895
+ * without spaces (Chinese, Japanese).
896
+ */
897
+ function placeholderCuts(text) {
898
+ const words = /* @__PURE__ */ new Set();
899
+ const chars = [];
900
+ const Segmenter = typeof Intl === "undefined" ? void 0 : Intl.Segmenter;
901
+ if (Segmenter) {
902
+ const parts = Array.from(new Segmenter(void 0, { granularity: "word" }).segment(text));
903
+ parts.forEach((part, i) => {
904
+ const next = parts[i + 1];
905
+ if (part.isWordLike && next && !next.isWordLike) words.add(next.index);
906
+ });
907
+ } else for (const space of text.matchAll(/\S\s/g)) words.add(space.index + 1);
908
+ const graphemes = Segmenter ? Array.from(new Segmenter(void 0, { granularity: "grapheme" }).segment(text), (g) => ({
909
+ index: g.index,
910
+ text: g.segment
911
+ })) : Array.from(text).reduce((all, ch) => {
912
+ const last = all.at(-1);
913
+ all.push({
914
+ index: last ? last.index + last.text.length : 0,
915
+ text: ch
916
+ });
917
+ return all;
918
+ }, []);
919
+ graphemes.forEach((g, i) => {
920
+ if (i === 0) return;
921
+ const before = graphemes[i - 1].text;
922
+ if (NO_SPACES.test(before) && NO_SPACES.test(g.text)) words.add(g.index);
923
+ chars.push(g.index);
924
+ });
925
+ return {
926
+ words: [...words].sort((a, b) => a - b),
927
+ chars
928
+ };
929
+ }
930
+ /**
931
+ * The placeholder the field shows in `room` px: the whole one when it fits,
932
+ * else its longest prefix that ends at a cut and, trailing spaces trimmed,
933
+ * fits with "…" after it ("Search…", never "Search …"). A cut between
934
+ * characters is only for a first word too long for the room. `width` reads a
935
+ * text's width in the field's font.
936
+ */
937
+ function fitPlaceholder(text, room, width) {
938
+ if (width(text) <= room) return text;
939
+ const { words, chars } = placeholderCuts(text);
940
+ const longest = (cuts) => {
941
+ let found = null;
942
+ let low = 0;
943
+ let high = cuts.length - 1;
944
+ while (low <= high) {
945
+ const mid = low + high >> 1;
946
+ const shown = text.slice(0, cuts[mid]).trimEnd();
947
+ if (shown !== "" && width(shown + ELLIPSIS) <= room) {
948
+ found = shown + ELLIPSIS;
949
+ low = mid + 1;
950
+ } else high = mid - 1;
951
+ }
952
+ return found;
953
+ };
954
+ return longest(words) ?? longest(chars) ?? (width(ELLIPSIS) <= room ? ELLIPSIS : "");
955
+ }
884
956
  /**
885
957
  * The search field of a list's toolbar: it fills the rest of its line and
886
958
  * keeps at least the room its icon and its whole placeholder take, up to
887
- * 160px (measured, or `minWidth`), so the toolbar's other steps come before
959
+ * 120px (measured, or `minWidth`), so the toolbar's other steps come before
888
960
  * it is squeezed. From `collapseOrder` on it is a search icon button at the
889
961
  * start of the line, which opens the field in the scope's place, More and the
890
962
  * primary action kept at the line's end; the
891
963
  * field closes once it is empty and loses focus, or on Esc. A search with
892
964
  * text stays open. Put it straight in the FitToolbar, between its groups.
893
965
  */
894
- function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onValueChange, minWidth, collapseOrder, icon = /* @__PURE__ */ jsx(Search, {}), clearLabel, className, style, onKeyDown, disabled, ref, ...props }) {
966
+ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onValueChange, minWidth, collapseOrder, icon = /* @__PURE__ */ jsx(Search, {}), clearLabel, className, style, onKeyDown, disabled, placeholder: hint, ref, ...props }) {
895
967
  const { search, searchButton, searchSpacer, searchField, searchInput, searchSizer } = fitToolbar();
896
968
  const item = useFitItem({ collapse: collapseOrder });
897
969
  const collapsed = item.taken("collapse");
@@ -907,6 +979,7 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
907
979
  const [setInput] = useMergedRef(input, ref);
908
980
  const sizer = useRef(null);
909
981
  const sizerText = useRef(null);
982
+ const probe = useRef(null);
910
983
  const focusOnOpen = useRef(false);
911
984
  const focusButtonOnClose = useRef(false);
912
985
  useLayoutEffect(() => {
@@ -925,7 +998,7 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
925
998
  };
926
999
  }, []);
927
1000
  const [measured, setMeasured] = useState(null);
928
- const placeholder = typeof props.placeholder === "string" ? props.placeholder : label;
1001
+ const placeholder = typeof hint === "string" ? hint : label;
929
1002
  useLayoutEffect(() => {
930
1003
  const copy = sizer.current;
931
1004
  const words = sizerText.current;
@@ -938,7 +1011,7 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
938
1011
  words.style.paddingInlineEnd = style.paddingInlineEnd;
939
1012
  const whole = Math.ceil(copy.getBoundingClientRect().width);
940
1013
  if (whole <= 0) return;
941
- const width = Math.min(whole, 160);
1014
+ const width = Math.min(whole, 120);
942
1015
  wrapper.current?.style.setProperty("--fit-search-min", `${width}px`);
943
1016
  setMeasured(width);
944
1017
  };
@@ -957,6 +1030,54 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
957
1030
  icon
958
1031
  ]);
959
1032
  const least = minWidth ?? measured;
1033
+ const [shownHint, setShownHint] = useState(void 0);
1034
+ useLayoutEffect(() => {
1035
+ const real = input.current;
1036
+ const ruler = probe.current;
1037
+ if (typeof hint !== "string" || !real || !ruler) {
1038
+ setShownHint(void 0);
1039
+ return;
1040
+ }
1041
+ const widths = /* @__PURE__ */ new Map();
1042
+ let frame = 0;
1043
+ const fit = () => {
1044
+ frame = 0;
1045
+ const style = getComputedStyle(real);
1046
+ const room = real.getBoundingClientRect().width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) - parseFloat(style.borderLeftWidth) - parseFloat(style.borderRightWidth);
1047
+ if (room <= 0) return;
1048
+ const font = `${style.font}|${style.letterSpacing}`;
1049
+ ruler.style.font = style.font;
1050
+ ruler.style.letterSpacing = style.letterSpacing;
1051
+ const width = (text) => {
1052
+ const key = `${font}\n${text}`;
1053
+ let w = widths.get(key);
1054
+ if (w === void 0) {
1055
+ ruler.textContent = text;
1056
+ w = ruler.getBoundingClientRect().width;
1057
+ widths.set(key, w);
1058
+ }
1059
+ return w;
1060
+ };
1061
+ setShownHint(fitPlaceholder(hint, room, width));
1062
+ };
1063
+ const later = () => {
1064
+ if (frame === 0) frame = requestAnimationFrame(fit);
1065
+ };
1066
+ const refit = () => {
1067
+ widths.clear();
1068
+ later();
1069
+ };
1070
+ fit();
1071
+ const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(later);
1072
+ resize?.observe(real);
1073
+ const fonts = typeof document === "undefined" ? void 0 : document.fonts;
1074
+ fonts?.addEventListener?.("loadingdone", refit);
1075
+ return () => {
1076
+ if (frame !== 0) cancelAnimationFrame(frame);
1077
+ resize?.disconnect();
1078
+ fonts?.removeEventListener?.("loadingdone", refit);
1079
+ };
1080
+ }, [hint]);
960
1081
  const change = useCallback((next) => {
961
1082
  setOwn(next);
962
1083
  onValueChange?.(next);
@@ -1044,12 +1165,12 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
1044
1165
  style: least === null ? void 0 : { "--fit-search-min": `${least}px` },
1045
1166
  ...item.props,
1046
1167
  children: [
1047
- /* @__PURE__ */ jsx("div", {
1168
+ /* @__PURE__ */ jsxs("div", {
1048
1169
  "aria-hidden": true,
1049
1170
  inert: true,
1050
1171
  "data-slot": "fit-toolbar-search-sizer",
1051
1172
  className: searchSizer(),
1052
- children: /* @__PURE__ */ jsxs(InputGroup, {
1173
+ children: [/* @__PURE__ */ jsxs(InputGroup, {
1053
1174
  ref: sizer,
1054
1175
  size: "md",
1055
1176
  className: "w-max",
@@ -1058,7 +1179,10 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
1058
1179
  className: "whitespace-pre",
1059
1180
  children: placeholder
1060
1181
  })]
1061
- })
1182
+ }), /* @__PURE__ */ jsx("span", {
1183
+ ref: probe,
1184
+ className: "absolute whitespace-pre"
1185
+ })]
1062
1186
  }),
1063
1187
  /* @__PURE__ */ jsxs(Tooltip, {
1064
1188
  disabled: !collapsed || open,
@@ -1104,6 +1228,7 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
1104
1228
  className: searchInput(),
1105
1229
  disabled,
1106
1230
  ...props,
1231
+ placeholder: shownHint ?? hint,
1107
1232
  value: text,
1108
1233
  onValueChange: change,
1109
1234
  onKeyDown: handleKeyDown
@@ -44,7 +44,7 @@ const fitToolbar = tv({
44
44
  "in-data-fit-collapse:data-open:absolute in-data-fit-collapse:data-open:min-w-0",
45
45
  "in-data-fit-collapse:focus-within:absolute in-data-fit-collapse:focus-within:min-w-0"
46
46
  ],
47
- searchInput: ["[&::-webkit-search-cancel-button]:appearance-none", "text-ellipsis placeholder:text-ellipsis"]
47
+ searchInput: ["[&::-webkit-search-cancel-button]:appearance-none", "text-ellipsis"]
48
48
  },
49
49
  variants: {
50
50
  count: { true: { pickerValue: ["font-mono text-mono-meta"] } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/raft-ui",
3
- "version": "0.89.0",
3
+ "version": "0.91.0",
4
4
  "description": "React components and CSS foundations for the Raft Design System.",
5
5
  "keywords": [
6
6
  "agent-skills",