@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).
|
|
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 =
|
|
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` (
|
|
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
|
-
*
|
|
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).
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
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,
|
|
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__ */
|
|
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
|
|
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"] } },
|