@botiverse/raft-ui 0.90.0 → 0.92.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,20 @@
1
+ ## [0.92.0](https://github.com/botiverse/raft-design/compare/v0.91.0...v0.92.0) (2026-11-11)
2
+
3
+ ### Fixed
4
+
5
+ - **Components used only on an app's lazy pages stay out of the chunk that loads raft-ui first.** `dist/index.mjs` and `dist/wip.mjs` are now the drag Escape guard import plus `export * from "./index-exports.mjs"` / `"./wip-exports.mjs"`; the names live in those two modules, which are not listed in `sideEffects`. Exports and types are unchanged, and the guard still installs when raft-ui loads.
6
+ - Why: both entries are in `sideEffects` so a bundler keeps the guard import, but a module with side effects keeps every import it has, so Rolldown put every raft-ui component an app used anywhere (the task board, InboxItem, SelectionToolbar, Meter, Search…) into that first chunk. Raft Desktop's first screen grew past its size gate (Tenny, 2026-11-11, #proj-raft-ui:49dd2bf4); with this and slock's startup closure reading the barrel, it is about 10.7 KiB gzip smaller.
7
+ - Affects: nothing in an app's code. A bundler that follows `sideEffects` can now leave unused components out of the first chunk.
8
+
9
+ ## [0.91.0](https://github.com/botiverse/raft-design/compare/v0.90.0...v0.91.0) (2026-11-10)
10
+
11
+ ### Fixed
12
+
13
+ - **`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.
14
+ - 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).
15
+ - 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.
16
+ - Docs: https://raft-design.botiverse.build/components/fit-toolbar/
17
+
1
18
  ## [0.90.0](https://github.com/botiverse/raft-design/compare/v0.89.0...v0.90.0) (2026-11-10)
2
19
 
3
20
  ### Changed
@@ -292,5 +292,5 @@ export type FitToolbarSearchProps = Omit<InputGroupInputProps, "className" | "va
292
292
  * field closes once it is empty and loses focus, or on Esc. A search with
293
293
  * text stays open. Put it straight in the FitToolbar, between its groups.
294
294
  */
295
- 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;
296
296
  //#endregion
@@ -885,6 +885,74 @@ function coverScope(root, search) {
885
885
  * isn't capped.
886
886
  */
887
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
+ }
888
956
  /**
889
957
  * The search field of a list's toolbar: it fills the rest of its line and
890
958
  * keeps at least the room its icon and its whole placeholder take, up to
@@ -895,7 +963,7 @@ const FIT_SEARCH_MAX_LEAST = 120;
895
963
  * field closes once it is empty and loses focus, or on Esc. A search with
896
964
  * text stays open. Put it straight in the FitToolbar, between its groups.
897
965
  */
898
- 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 }) {
899
967
  const { search, searchButton, searchSpacer, searchField, searchInput, searchSizer } = fitToolbar();
900
968
  const item = useFitItem({ collapse: collapseOrder });
901
969
  const collapsed = item.taken("collapse");
@@ -911,6 +979,7 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
911
979
  const [setInput] = useMergedRef(input, ref);
912
980
  const sizer = useRef(null);
913
981
  const sizerText = useRef(null);
982
+ const probe = useRef(null);
914
983
  const focusOnOpen = useRef(false);
915
984
  const focusButtonOnClose = useRef(false);
916
985
  useLayoutEffect(() => {
@@ -929,7 +998,7 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
929
998
  };
930
999
  }, []);
931
1000
  const [measured, setMeasured] = useState(null);
932
- const placeholder = typeof props.placeholder === "string" ? props.placeholder : label;
1001
+ const placeholder = typeof hint === "string" ? hint : label;
933
1002
  useLayoutEffect(() => {
934
1003
  const copy = sizer.current;
935
1004
  const words = sizerText.current;
@@ -961,6 +1030,54 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
961
1030
  icon
962
1031
  ]);
963
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]);
964
1081
  const change = useCallback((next) => {
965
1082
  setOwn(next);
966
1083
  onValueChange?.(next);
@@ -1048,12 +1165,12 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
1048
1165
  style: least === null ? void 0 : { "--fit-search-min": `${least}px` },
1049
1166
  ...item.props,
1050
1167
  children: [
1051
- /* @__PURE__ */ jsx("div", {
1168
+ /* @__PURE__ */ jsxs("div", {
1052
1169
  "aria-hidden": true,
1053
1170
  inert: true,
1054
1171
  "data-slot": "fit-toolbar-search-sizer",
1055
1172
  className: searchSizer(),
1056
- children: /* @__PURE__ */ jsxs(InputGroup, {
1173
+ children: [/* @__PURE__ */ jsxs(InputGroup, {
1057
1174
  ref: sizer,
1058
1175
  size: "md",
1059
1176
  className: "w-max",
@@ -1062,7 +1179,10 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
1062
1179
  className: "whitespace-pre",
1063
1180
  children: placeholder
1064
1181
  })]
1065
- })
1182
+ }), /* @__PURE__ */ jsx("span", {
1183
+ ref: probe,
1184
+ className: "absolute whitespace-pre"
1185
+ })]
1066
1186
  }),
1067
1187
  /* @__PURE__ */ jsxs(Tooltip, {
1068
1188
  disabled: !collapsed || open,
@@ -1108,6 +1228,7 @@ function FitToolbarSearch({ "aria-label": label, value, defaultValue = "", onVal
1108
1228
  className: searchInput(),
1109
1229
  disabled,
1110
1230
  ...props,
1231
+ placeholder: shownHint ?? hint,
1111
1232
  value: text,
1112
1233
  onValueChange: change,
1113
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"] } },