cursedbelt 5.22.0 → 5.23.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/react/components/SearchInput.d.ts +15 -1
- package/dist/react/components/SearchInput.d.ts.map +1 -1
- package/dist/react/components/SearchInput.js +36 -4
- package/dist/react/components/SearchInput.js.map +1 -1
- package/dist/react/components/searchInput.css +179 -0
- package/dist/react/hooks/useScrollMemory.d.ts +3 -1
- package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
- package/dist/react/hooks/useScrollMemory.js +66 -47
- package/dist/react/hooks/useScrollMemory.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +8 -2
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +2 -2
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +1 -1
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +16 -4
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryReturn.d.ts +50 -0
- package/dist/react/media-gallery/galleryReturn.d.ts.map +1 -0
- package/dist/react/media-gallery/galleryReturn.js +221 -0
- package/dist/react/media-gallery/galleryReturn.js.map +1 -0
- package/package.json +8 -1
- package/src/demoFixture.spec.ts +4 -2
- package/src/react/components/SearchInput.spec.tsx +57 -0
- package/src/react/components/SearchInput.tsx +74 -20
- package/src/react/components/searchInput.css +179 -0
- package/src/react/hooks/useScrollMemory.spec.tsx +38 -0
- package/src/react/hooks/useScrollMemory.ts +82 -47
- package/src/react/media/DenseGalleryShell.tsx +9 -1
- package/src/react/media-gallery/GalleryTable.tsx +2 -0
- package/src/react/media-gallery/MediaGallery.tsx +19 -1
- package/src/react/media-gallery/galleryReturn.ts +248 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closing a file puts the grid back where you were — task 2255.
|
|
3
|
+
*
|
|
4
|
+
* Owner, 2026-09-28, after flix's "Back keeps your place": *"is a common enough need … I want it
|
|
5
|
+
* to be in cb … find other apps we have that have scrolling and similar issues where we can't
|
|
6
|
+
* go back to the right place like flix had"*.
|
|
7
|
+
*
|
|
8
|
+
* `MediaGallery` draws `DenseGalleryDetail` while a file is open and `DenseGalleryShell`
|
|
9
|
+
* otherwise. They are different trees, so closing a file — by ✕, by a deep link's Back
|
|
10
|
+
* (`useGalleryDeepLink`'s `?i=`), by deleting it — MOUNTS A NEW GRID, and its scroller starts
|
|
11
|
+
* at zero. Measured in Chromium before this (`e2e/gallery-return.spec.ts`, 240 tiles, scrolled
|
|
12
|
+
* to 3,000px): every close landed at scrollTop 0, at phone and desktop size, two hundred tiles
|
|
13
|
+
* from where you had been.
|
|
14
|
+
*
|
|
15
|
+
* 🔴 Why not `useScrollMemory`. That hook is per HISTORY ENTRY, and a gallery's open/close is
|
|
16
|
+
* a history entry only when its consumer deep-links it. An uncontrolled gallery closes with no
|
|
17
|
+
* navigation at all, so the memory has to live with the gallery — which is also what keeps
|
|
18
|
+
* every consumer from having to wire anything.
|
|
19
|
+
*
|
|
20
|
+
* Two rules, in order:
|
|
21
|
+
* 1. **The offset comes back** — the browse scroller's own `scrollTop` (the tile grid's
|
|
22
|
+
* `.cbgd-grid`, or the table's `.cbgt-root`), recorded while you browse and put back when
|
|
23
|
+
* the view returns, if it returns in the same grid/table mode. It retries each frame while
|
|
24
|
+
* the virtualized track is still growing into the offset, and a touch, wheel, key or
|
|
25
|
+
* pointer press cancels it — the viewer always wins.
|
|
26
|
+
* 2. **…then the last file SHOWN is on screen.** Stepping through twenty files moves you: the
|
|
27
|
+
* place you are now is the last file the stage showed, not the one you opened. When that
|
|
28
|
+
* file's tile is not visible after rule 1 it is brought to the middle of the scroller. A
|
|
29
|
+
* tile that is already even partly visible is left alone — a grid that jumps a few pixels
|
|
30
|
+
* under a tile you can see reads as a bug (the same limit the strip's reveal keeps).
|
|
31
|
+
* A virtualized tile that is not mounted is found by arithmetic on the tiles that are:
|
|
32
|
+
* their row pitch and column count place it, and the next frame centres it exactly.
|
|
33
|
+
*/
|
|
34
|
+
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
35
|
+
/** The browse view's scroller in either view: the tile grid, or the table's frame. */
|
|
36
|
+
const SCROLLER = '.cbgd-grid, .cbgt-root';
|
|
37
|
+
/** Every tile and every table row carries its item's id under this attribute. */
|
|
38
|
+
export const ITEM_ID_ATTR = 'data-item-id';
|
|
39
|
+
/** How long a return may keep trying before it settles for what the scroller can hold. */
|
|
40
|
+
const DEADLINE_MS = 1500;
|
|
41
|
+
const CANCEL_EVENTS = ['wheel', 'touchstart', 'pointerdown', 'keydown'];
|
|
42
|
+
export function useGalleryReturn({ rootRef, browsing, viewMode, shownId, displayIds, }) {
|
|
43
|
+
const saved = useRef(null);
|
|
44
|
+
const lastShown = useRef(null);
|
|
45
|
+
const restoring = useRef(false);
|
|
46
|
+
const wasBrowsing = useRef(browsing);
|
|
47
|
+
// Read through refs at fire time: they are inputs the return needs CURRENT, not triggers.
|
|
48
|
+
const modeRef = useRef(viewMode);
|
|
49
|
+
const idsRef = useRef(displayIds);
|
|
50
|
+
modeRef.current = viewMode;
|
|
51
|
+
idsRef.current = displayIds;
|
|
52
|
+
if (shownId)
|
|
53
|
+
lastShown.current = shownId;
|
|
54
|
+
// Record the browse scroller's offset while browsing. Capture phase, because `scroll` does
|
|
55
|
+
// not bubble and the scroller is whichever of the two views is mounted.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const root = rootRef.current;
|
|
58
|
+
if (!browsing || !root)
|
|
59
|
+
return;
|
|
60
|
+
const onScroll = (event) => {
|
|
61
|
+
const target = event.target;
|
|
62
|
+
if (restoring.current || !(target instanceof HTMLElement) || !target.matches(SCROLLER))
|
|
63
|
+
return;
|
|
64
|
+
saved.current = { mode: modeRef.current, top: target.scrollTop };
|
|
65
|
+
};
|
|
66
|
+
root.addEventListener('scroll', onScroll, { capture: true, passive: true });
|
|
67
|
+
return () => root.removeEventListener('scroll', onScroll, { capture: true });
|
|
68
|
+
}, [browsing, rootRef]);
|
|
69
|
+
// A layout effect, so the offset is back before the returning grid is ever painted at zero.
|
|
70
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `browsing` is the one trigger — a return is the moment it turns true. Everything else is read through refs above, on purpose: a re-sort or a new page of items must not replay a return.
|
|
71
|
+
useLayoutEffect(() => {
|
|
72
|
+
const returning = browsing && !wasBrowsing.current;
|
|
73
|
+
wasBrowsing.current = browsing;
|
|
74
|
+
if (!returning)
|
|
75
|
+
return;
|
|
76
|
+
const scroller = rootRef.current?.querySelector(SCROLLER);
|
|
77
|
+
if (!scroller)
|
|
78
|
+
return;
|
|
79
|
+
const target = saved.current?.mode === modeRef.current ? saved.current.top : null;
|
|
80
|
+
const revealId = lastShown.current;
|
|
81
|
+
if (target === null && !revealId)
|
|
82
|
+
return;
|
|
83
|
+
return returnTo(scroller, target, revealId, idsRef, () => {
|
|
84
|
+
restoring.current = false;
|
|
85
|
+
saved.current = { mode: modeRef.current, top: scroller.scrollTop };
|
|
86
|
+
}, restoring);
|
|
87
|
+
}, [browsing]);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Put `scroller` back at `target`, then bring `revealId`'s tile into view. Returns the cancel.
|
|
91
|
+
*/
|
|
92
|
+
function returnTo(scroller, target, revealId, idsRef, done, restoring) {
|
|
93
|
+
// Each phase gets its own deadline, so a list that never grows back into the offset still
|
|
94
|
+
// leaves the reveal its full budget.
|
|
95
|
+
let started = performance.now();
|
|
96
|
+
let frame = 0;
|
|
97
|
+
let phase = target === null ? 'reveal' : 'offset';
|
|
98
|
+
let finished = false;
|
|
99
|
+
restoring.current = true;
|
|
100
|
+
const finish = () => {
|
|
101
|
+
if (finished)
|
|
102
|
+
return;
|
|
103
|
+
finished = true;
|
|
104
|
+
cancelAnimationFrame(frame);
|
|
105
|
+
for (const type of CANCEL_EVENTS)
|
|
106
|
+
scroller.removeEventListener(type, finish);
|
|
107
|
+
done();
|
|
108
|
+
};
|
|
109
|
+
for (const type of CANCEL_EVENTS)
|
|
110
|
+
scroller.addEventListener(type, finish, { passive: true });
|
|
111
|
+
const tick = () => {
|
|
112
|
+
if (finished)
|
|
113
|
+
return;
|
|
114
|
+
const late = performance.now() - started > DEADLINE_MS;
|
|
115
|
+
if (phase === 'offset' && target !== null) {
|
|
116
|
+
scroller.scrollTop = target;
|
|
117
|
+
// Reached, or the list is simply shorter now (a filter, a delete): settle for its end.
|
|
118
|
+
if (Math.abs(scroller.scrollTop - target) < 1 || late) {
|
|
119
|
+
phase = 'reveal';
|
|
120
|
+
started = performance.now();
|
|
121
|
+
}
|
|
122
|
+
frame = requestAnimationFrame(tick);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const ids = idsRef.current ?? [];
|
|
126
|
+
const index = revealId ? ids.indexOf(revealId) : -1;
|
|
127
|
+
if (index < 0 || late) {
|
|
128
|
+
finish();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const tile = scroller.querySelector(`[${ITEM_ID_ATTR}="${cssEscape(revealId ?? '')}"]`);
|
|
132
|
+
const band = visibleBand(scroller);
|
|
133
|
+
if (tile) {
|
|
134
|
+
const box = tile.getBoundingClientRect();
|
|
135
|
+
const visible = box.bottom > band.top && box.top < band.bottom;
|
|
136
|
+
// Rule 2's limit: a tile you can already see is not moved — unless WE just jumped to an
|
|
137
|
+
// estimate of it, which is only ever close.
|
|
138
|
+
if (!visible || phase === 'centre')
|
|
139
|
+
scroller.scrollTop += centreDelta(box, band);
|
|
140
|
+
finish();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// 🔴 Not mounted — but first make sure the VIRTUALIZER has caught up with the offset just
|
|
144
|
+
// set. The table re-windows on its own scroll state a render later, so for a frame or two
|
|
145
|
+
// after a jump it still holds the rows from the top; estimating off those would place a
|
|
146
|
+
// tile that is about to appear right where you are. Nothing mounted in the band = wait.
|
|
147
|
+
if (!mountedInBand(scroller, band)) {
|
|
148
|
+
frame = requestAnimationFrame(tick);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Truly windowed out. Place it by the tiles that ARE mounted.
|
|
152
|
+
const estimate = estimateTop(scroller, band, ids, index);
|
|
153
|
+
if (estimate === null) {
|
|
154
|
+
finish();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
scroller.scrollTop = estimate;
|
|
158
|
+
phase = 'centre';
|
|
159
|
+
frame = requestAnimationFrame(tick);
|
|
160
|
+
};
|
|
161
|
+
tick();
|
|
162
|
+
return () => {
|
|
163
|
+
if (finished)
|
|
164
|
+
return;
|
|
165
|
+
finished = true;
|
|
166
|
+
cancelAnimationFrame(frame);
|
|
167
|
+
for (const type of CANCEL_EVENTS)
|
|
168
|
+
scroller.removeEventListener(type, finish);
|
|
169
|
+
restoring.current = false;
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** The part of the scroller a person can see — below the table's sticky header, if any. */
|
|
173
|
+
function visibleBand(scroller) {
|
|
174
|
+
const view = scroller.getBoundingClientRect();
|
|
175
|
+
const head = scroller.querySelector('thead');
|
|
176
|
+
const top = head ? Math.max(view.top, head.getBoundingClientRect().bottom) : view.top;
|
|
177
|
+
return { top, bottom: view.bottom };
|
|
178
|
+
}
|
|
179
|
+
/** Does any tile or row sit in the visible band — i.e. has the window caught up? */
|
|
180
|
+
function mountedInBand(scroller, band) {
|
|
181
|
+
for (const el of scroller.querySelectorAll(`[${ITEM_ID_ATTR}]`)) {
|
|
182
|
+
const box = el.getBoundingClientRect();
|
|
183
|
+
if (box.bottom > band.top && box.top < band.bottom)
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
function centreDelta(box, band) {
|
|
189
|
+
return box.top + box.height / 2 - (band.top + band.bottom) / 2;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* A `scrollTop` that centres the tile at `index`, derived from the mounted ones: the first
|
|
193
|
+
* mounted row gives the column count (only the LAST row of a grid can be short), and the next
|
|
194
|
+
* row down gives the pitch. A table is the same arithmetic with one column.
|
|
195
|
+
*/
|
|
196
|
+
function estimateTop(scroller, band, ids, index) {
|
|
197
|
+
const placed = [];
|
|
198
|
+
for (const el of scroller.querySelectorAll(`[${ITEM_ID_ATTR}]`)) {
|
|
199
|
+
const at = ids.indexOf(el.getAttribute(ITEM_ID_ATTR) ?? '');
|
|
200
|
+
if (at < 0)
|
|
201
|
+
continue;
|
|
202
|
+
const box = el.getBoundingClientRect();
|
|
203
|
+
placed.push({ index: at, top: box.top - band.top + scroller.scrollTop, height: box.height });
|
|
204
|
+
}
|
|
205
|
+
if (placed.length === 0)
|
|
206
|
+
return null;
|
|
207
|
+
placed.sort((a, b) => a.index - b.index);
|
|
208
|
+
const anchor = placed[0];
|
|
209
|
+
const cols = Math.max(1, placed.filter((p) => Math.abs(p.top - anchor.top) < 1).length);
|
|
210
|
+
const below = placed.find((p) => p.top - anchor.top >= 1);
|
|
211
|
+
const rowOf = (at) => Math.floor(at / cols);
|
|
212
|
+
const pitch = below ? (below.top - anchor.top) / (rowOf(below.index) - rowOf(anchor.index) || 1) : anchor.height;
|
|
213
|
+
const rows = rowOf(index) - rowOf(anchor.index);
|
|
214
|
+
const top = anchor.top + rows * pitch;
|
|
215
|
+
return Math.max(0, top + anchor.height / 2 - (band.bottom - band.top) / 2);
|
|
216
|
+
}
|
|
217
|
+
/** `CSS.escape` where it exists (every browser); ids are app-chosen strings. */
|
|
218
|
+
function cssEscape(value) {
|
|
219
|
+
return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value.replace(/["\\]/g, '\\$&');
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=galleryReturn.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"galleryReturn.js","sourceRoot":"","sources":["../../../src/react/media-gallery/galleryReturn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAkB,SAAS,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAE3E,sFAAsF;AACtF,MAAM,QAAQ,GAAG,wBAAwB,CAAC;AAC1C,iFAAiF;AACjF,MAAM,CAAC,MAAM,YAAY,GAAG,cAAc,CAAC;AAC3C,0FAA0F;AAC1F,MAAM,WAAW,GAAG,IAAI,CAAC;AACzB,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,SAAS,CAAU,CAAC;AAejF,MAAM,UAAU,gBAAgB,CAAC,EAC/B,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,UAAU,GACW;IACrB,MAAM,KAAK,GAAG,MAAM,CAAuC,IAAI,CAAC,CAAC;IACjE,MAAM,SAAS,GAAG,MAAM,CAAgB,IAAI,CAAC,CAAC;IAC9C,MAAM,SAAS,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAChC,MAAM,WAAW,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;IACrC,0FAA0F;IAC1F,MAAM,OAAO,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;IACjC,MAAM,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC;IAClC,OAAO,CAAC,OAAO,GAAG,QAAQ,CAAC;IAC3B,MAAM,CAAC,OAAO,GAAG,UAAU,CAAC;IAC5B,IAAI,OAAO;QAAE,SAAS,CAAC,OAAO,GAAG,OAAO,CAAC;IAEzC,2FAA2F;IAC3F,wEAAwE;IACxE,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC;QAC7B,IAAI,CAAC,QAAQ,IAAI,CAAC,IAAI;YAAE,OAAO;QAC/B,MAAM,QAAQ,GAAG,CAAC,KAAY,EAAQ,EAAE;YACtC,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;YAC5B,IAAI,SAAS,CAAC,OAAO,IAAI,CAAC,CAAC,MAAM,YAAY,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC;gBAAE,OAAO;YAC/F,KAAK,CAAC,OAAO,GAAG,EAAE,IAAI,EAAE,OAAO,CAAC,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC;QACnE,CAAC,CAAC;QACF,IAAI,CAAC,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;QAC5E,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;IAC/E,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC;IAExB,4FAA4F;IAC5F,oPAAoP;IACpP,eAAe,CAAC,GAAG,EAAE;QACnB,MAAM,SAAS,GAAG,QAAQ,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC;QACnD,WAAW,CAAC,OAAO,GAAG,QAAQ,CAAC;QAC/B,IAAI,CAAC,SAAS;YAAE,OAAO;QACvB,MAAM,QAAQ,GAAG,OAAO,CAAC,OAAO,EAAE,aAAa,CAAc,QAAQ,CAAC,CAAC;QACvE,IAAI,CAAC,QAAQ;YAAE,OAAO;QACtB,MAAM,MAAM,GAAG,KAAK,CAAC,OAAO,EAAE,IAAI,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;QAClF,MAAM,QAAQ,GAAG,SAAS,CAAC,OAAO,CAAC;QACnC,IAAI,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ;YAAE,OAAO;QACzC,OAAO,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE;YACvD,SAAS,CAAC,OAAO,GAAG,KAAK,CAAC;YAC1B,KAAK,CAAC,OAAO,GAAG,EAAE,IAAI,EAAE,OAAO,CAAC,OAAO,EAAE,GAAG,EAAE,QAAQ,CAAC,SAAS,EAAE,CAAC;QACrE,CAAC,EAAE,SAAS,CAAC,CAAC;IAChB,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC;AACjB,CAAC;AAED;;GAEG;AACH,SAAS,QAAQ,CACf,QAAqB,EACrB,MAAqB,EACrB,QAAuB,EACvB,MAAoC,EACpC,IAAgB,EAChB,SAA+B;IAE/B,0FAA0F;IAC1F,qCAAqC;IACrC,IAAI,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;IAChC,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,KAAK,GAAmC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC;IAClF,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,SAAS,CAAC,OAAO,GAAG,IAAI,CAAC;IAEzB,MAAM,MAAM,GAAG,GAAS,EAAE;QACxB,IAAI,QAAQ;YAAE,OAAO;QACrB,QAAQ,GAAG,IAAI,CAAC;QAChB,oBAAoB,CAAC,KAAK,CAAC,CAAC;QAC5B,KAAK,MAAM,IAAI,IAAI,aAAa;YAAE,QAAQ,CAAC,mBAAmB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QAC7E,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;IACF,KAAK,MAAM,IAAI,IAAI,aAAa;QAAE,QAAQ,CAAC,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;IAE7F,MAAM,IAAI,GAAG,GAAS,EAAE;QACtB,IAAI,QAAQ;YAAE,OAAO;QACrB,MAAM,IAAI,GAAG,WAAW,CAAC,GAAG,EAAE,GAAG,OAAO,GAAG,WAAW,CAAC;QACvD,IAAI,KAAK,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YAC1C,QAAQ,CAAC,SAAS,GAAG,MAAM,CAAC;YAC5B,uFAAuF;YACvF,IAAI,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,SAAS,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC;gBACtD,KAAK,GAAG,QAAQ,CAAC;gBACjB,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;YAC9B,CAAC;YACD,KAAK,GAAG,qBAAqB,CAAC,IAAI,CAAC,CAAC;YACpC,OAAO;QACT,CAAC;QACD,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,IAAI,EAAE,CAAC;QACjC,MAAM,KAAK,GAAG,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACpD,IAAI,KAAK,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC;YACtB,MAAM,EAAE,CAAC;YACT,OAAO;QACT,CAAC;QACD,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAc,IAAI,YAAY,KAAK,SAAS,CAAC,QAAQ,IAAI,EAAE,CAAC,IAAI,CAAC,CAAC;QACrG,MAAM,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC;QACnC,IAAI,IAAI,EAAE,CAAC;YACT,MAAM,GAAG,GAAG,IAAI,CAAC,qBAAqB,EAAE,CAAC;YACzC,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC;YAC/D,wFAAwF;YACxF,4CAA4C;YAC5C,IAAI,CAAC,OAAO,IAAI,KAAK,KAAK,QAAQ;gBAAE,QAAQ,CAAC,SAAS,IAAI,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YACjF,MAAM,EAAE,CAAC;YACT,OAAO;QACT,CAAC;QACD,0FAA0F;QAC1F,0FAA0F;QAC1F,wFAAwF;QACxF,wFAAwF;QACxF,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,EAAE,CAAC;YACnC,KAAK,GAAG,qBAAqB,CAAC,IAAI,CAAC,CAAC;YACpC,OAAO;QACT,CAAC;QACD,8DAA8D;QAC9D,MAAM,QAAQ,GAAG,WAAW,CAAC,QAAQ,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC;QACzD,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;YACtB,MAAM,EAAE,CAAC;YACT,OAAO;QACT,CAAC;QACD,QAAQ,CAAC,SAAS,GAAG,QAAQ,CAAC;QAC9B,KAAK,GAAG,QAAQ,CAAC;QACjB,KAAK,GAAG,qBAAqB,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC,CAAC;IAEF,IAAI,EAAE,CAAC;IACP,OAAO,GAAG,EAAE;QACV,IAAI,QAAQ;YAAE,OAAO;QACrB,QAAQ,GAAG,IAAI,CAAC;QAChB,oBAAoB,CAAC,KAAK,CAAC,CAAC;QAC5B,KAAK,MAAM,IAAI,IAAI,aAAa;YAAE,QAAQ,CAAC,mBAAmB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QAC7E,SAAS,CAAC,OAAO,GAAG,KAAK,CAAC;IAC5B,CAAC,CAAC;AACJ,CAAC;AAOD,2FAA2F;AAC3F,SAAS,WAAW,CAAC,QAAqB;IACxC,MAAM,IAAI,GAAG,QAAQ,CAAC,qBAAqB,EAAE,CAAC;IAC9C,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IAC7C,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,qBAAqB,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC;IACtF,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC;AACtC,CAAC;AAED,oFAAoF;AACpF,SAAS,aAAa,CAAC,QAAqB,EAAE,IAAU;IACtD,KAAK,MAAM,EAAE,IAAI,QAAQ,CAAC,gBAAgB,CAAc,IAAI,YAAY,GAAG,CAAC,EAAE,CAAC;QAC7E,MAAM,GAAG,GAAG,EAAE,CAAC,qBAAqB,EAAE,CAAC;QACvC,IAAI,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,GAAG,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC;IAClE,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,WAAW,CAAC,GAAY,EAAE,IAAU;IAC3C,OAAO,GAAG,CAAC,GAAG,GAAG,GAAG,CAAC,MAAM,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AACjE,CAAC;AAED;;;;GAIG;AACH,SAAS,WAAW,CAAC,QAAqB,EAAE,IAAU,EAAE,GAAsB,EAAE,KAAa;IAC3F,MAAM,MAAM,GAAqD,EAAE,CAAC;IACpE,KAAK,MAAM,EAAE,IAAI,QAAQ,CAAC,gBAAgB,CAAc,IAAI,YAAY,GAAG,CAAC,EAAE,CAAC;QAC7E,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,YAAY,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC,CAAC;QAC5D,IAAI,EAAE,GAAG,CAAC;YAAE,SAAS;QACrB,MAAM,GAAG,GAAG,EAAE,CAAC,qBAAqB,EAAE,CAAC;QACvC,MAAM,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;IAC/F,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;IACzC,MAAM,MAAM,GAAG,MAAM,CAAC,CAAC,CAA4B,CAAC;IACpD,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IACxF,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;IAC1D,MAAM,KAAK,GAAG,CAAC,EAAU,EAAU,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC;IAC5D,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC;IACjH,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAChD,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,GAAG,IAAI,GAAG,KAAK,CAAC;IACtC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;AAC7E,CAAC;AAED,gFAAgF;AAChF,SAAS,SAAS,CAAC,KAAa;IAC9B,OAAO,OAAO,GAAG,KAAK,WAAW,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;AACxG,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.23.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"./react/media/video-player",
|
|
32
32
|
"./react/pdf-viewer",
|
|
33
33
|
"./react/rich-text",
|
|
34
|
+
"./react/search-input",
|
|
34
35
|
"./react/spreadsheet",
|
|
35
36
|
"./react/workbook-viewer"
|
|
36
37
|
],
|
|
@@ -348,6 +349,12 @@
|
|
|
348
349
|
"source": "./src/react/components/ScrollArea.tsx",
|
|
349
350
|
"import": "./dist/react/components/ScrollArea.js"
|
|
350
351
|
},
|
|
352
|
+
"./react/search-input": {
|
|
353
|
+
"types": "./dist/react/components/SearchInput.d.ts",
|
|
354
|
+
"bun": "./src/react/components/SearchInput.tsx",
|
|
355
|
+
"source": "./src/react/components/SearchInput.tsx",
|
|
356
|
+
"import": "./dist/react/components/SearchInput.js"
|
|
357
|
+
},
|
|
351
358
|
"./react/secret-input": {
|
|
352
359
|
"types": "./dist/react/components/SecretInput.d.ts",
|
|
353
360
|
"bun": "./src/react/components/SecretInput.tsx",
|
package/src/demoFixture.spec.ts
CHANGED
|
@@ -101,8 +101,10 @@ describe('the e2e fixture exists', () => {
|
|
|
101
101
|
for (const spec of specs) {
|
|
102
102
|
const text = readFileSync(resolve(e2eDir, spec), 'utf8');
|
|
103
103
|
for (const [, url] of text.matchAll(/goto\('([^']+)'\)/g)) {
|
|
104
|
-
// '/' is index.html; '/table-layout.html' is that file in demo/.
|
|
105
|
-
|
|
104
|
+
// '/' is index.html; '/table-layout.html' is that file in demo/. A query or a hash
|
|
105
|
+
// (`/gallery-return.html?link`) picks a mode on the page, not a different file.
|
|
106
|
+
const path = url.replace(/[?#].*$/, '');
|
|
107
|
+
const file = path === '/' ? 'index.html' : path.replace(/^\//, '');
|
|
106
108
|
if (!existsSync(resolve(DEMO_ROOT, file))) missing.push(`${spec} → ${url}`);
|
|
107
109
|
}
|
|
108
110
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createRef } from 'react';
|
|
2
4
|
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
5
|
import { SearchInput } from './SearchInput.js';
|
|
4
6
|
|
|
@@ -56,4 +58,59 @@ describe('SearchInput', () => {
|
|
|
56
58
|
expect(badge(container)).toBeNull();
|
|
57
59
|
expect(getByRole('searchbox').className).not.toContain('pr-9');
|
|
58
60
|
});
|
|
61
|
+
|
|
62
|
+
test('no ✕ unless asked for — a box that never had one does not grow one', () => {
|
|
63
|
+
const { queryByRole } = render(<SearchInput value='the office' onChange={() => {}} />);
|
|
64
|
+
expect(queryByRole('button', { name: 'Clear search' })).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('clearable: the ✕ appears with text, empties the box, and leaves the caret in it', () => {
|
|
68
|
+
const onChange = mock((_: string) => {});
|
|
69
|
+
const empty = render(<SearchInput value='' onChange={onChange} clearable />);
|
|
70
|
+
expect(empty.queryByRole('button', { name: 'Clear search' })).toBeNull();
|
|
71
|
+
empty.unmount();
|
|
72
|
+
|
|
73
|
+
const { getByRole } = render(<SearchInput value='report' onChange={onChange} clearable />);
|
|
74
|
+
const input = getByRole('searchbox');
|
|
75
|
+
expect(input.getAttribute('data-trailing')).toBe('1');
|
|
76
|
+
fireEvent.click(getByRole('button', { name: 'Clear search' }));
|
|
77
|
+
expect(onChange).toHaveBeenLastCalledWith('');
|
|
78
|
+
expect(document.activeElement).toBe(input);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('badge and ✕ together: the field makes room for both', () => {
|
|
82
|
+
const { getByRole } = render(<SearchInput value='q3*report' onChange={() => {}} clearable />);
|
|
83
|
+
const input = getByRole('searchbox');
|
|
84
|
+
expect(input.getAttribute('data-trailing')).toBe('2');
|
|
85
|
+
expect(input.className).toContain('pr-16');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a ref and a data attribute reach the <input> — desk's ⌘F finds its field by one", () => {
|
|
89
|
+
const ref = createRef<HTMLInputElement>();
|
|
90
|
+
const { getByRole } = render(
|
|
91
|
+
<SearchInput ref={ref} value='' onChange={() => {}} data-find-field='true' clearable />,
|
|
92
|
+
);
|
|
93
|
+
const input = getByRole('searchbox');
|
|
94
|
+
expect(ref.current).toBe(input as HTMLInputElement);
|
|
95
|
+
expect(input.getAttribute('data-find-field')).toBe('true');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('every element carries its plain `cbsi-` class beside its utilities', () => {
|
|
99
|
+
const { container, getByRole } = render(<SearchInput value='the*the' onChange={() => {}} clearable />);
|
|
100
|
+
expect(container.querySelector('.cbsi')).not.toBeNull();
|
|
101
|
+
expect(container.querySelector('svg.cbsi-icon')).not.toBeNull();
|
|
102
|
+
expect(getByRole('searchbox').className).toContain('cbsi-input');
|
|
103
|
+
expect(badge(container)?.className).toContain('cbsi-badge');
|
|
104
|
+
expect(container.querySelector('button.cbsi-clear')).not.toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('🔴 the stylesheet is imported by the component and every rule sits in `@layer base`', () => {
|
|
108
|
+
// An import nobody makes ships a look nobody sees; an unlayered rule would override a
|
|
109
|
+
// Tailwind app's `inputClassName` (see the stylesheet's header). Both are silent in a DOM test.
|
|
110
|
+
const source = readFileSync(new URL('./SearchInput.tsx', import.meta.url), 'utf8');
|
|
111
|
+
expect(source).toContain("import './searchInput.css';");
|
|
112
|
+
const css = readFileSync(new URL('./searchInput.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
113
|
+
const outside = css.replace(/@layer base\s*\{[\s\S]*\}\s*$/, '').trim();
|
|
114
|
+
expect(outside).toBe('');
|
|
115
|
+
});
|
|
59
116
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Asterisk, Regex, Search, TriangleAlert } from 'lucide-react';
|
|
2
|
-
import { useMemo } from 'react';
|
|
1
|
+
import { Asterisk, Regex, Search, TriangleAlert, X } from 'lucide-react';
|
|
2
|
+
import { useMemo, useRef } from 'react';
|
|
3
3
|
import {
|
|
4
4
|
parseSearchPattern,
|
|
5
5
|
type SearchPattern,
|
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
import { cn } from '../lib/cn.js';
|
|
9
9
|
import { Input, type InputProps } from './Input.js';
|
|
10
10
|
import { Tooltip } from './Tooltip.js';
|
|
11
|
+
// 🔴 The look travels with the component — see the stylesheet's header for why every rule in it
|
|
12
|
+
// sits in `@layer base`, below the Tailwind utilities this file still carries.
|
|
13
|
+
import './searchInput.css';
|
|
11
14
|
|
|
12
15
|
export interface SearchInputProps extends Omit<InputProps, 'value' | 'onChange' | 'type'> {
|
|
13
16
|
value: string;
|
|
@@ -25,6 +28,12 @@ export interface SearchInputProps extends Omit<InputProps, 'value' | 'onChange'
|
|
|
25
28
|
className?: string;
|
|
26
29
|
/** Classes for the `<input>` itself — colours, radius, text size. */
|
|
27
30
|
inputClassName?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Draw a ✕ that empties the box (and keeps the caret in it) while there is text to clear.
|
|
33
|
+
* Default `false`, so a box that never had one does not grow one. The browser's own ✕ on a
|
|
34
|
+
* `type="search"` field is hidden while this is on, so there is only ever one.
|
|
35
|
+
*/
|
|
36
|
+
clearable?: boolean;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
const PLAIN_ONLY: SearchSyntaxOptions = { wildcard: false, regex: false };
|
|
@@ -64,6 +73,13 @@ function badgeFor(pattern: SearchPattern) {
|
|
|
64
73
|
* Parsing is the app's too: this component only DISPLAYS the pattern. An app
|
|
65
74
|
* that filters in memory calls `createSearchMatcher(value, syntax)`; one that
|
|
66
75
|
* queries a server parses there with the same options.
|
|
76
|
+
*
|
|
77
|
+
* ── In an app with no Tailwind ──────────────────────────────────────────────
|
|
78
|
+
* It draws from its own `searchInput.css` (5.23.0, task 2256), so import it by its
|
|
79
|
+
* own subpath, `cursedbelt/react/search-input`, rather than the `cursedbelt/react`
|
|
80
|
+
* barrel. Everything else a field needs passes straight to the `<input>`: `ref`,
|
|
81
|
+
* `autoFocus`, and any `data-*` attribute (desk's ⌘F finds its field by
|
|
82
|
+
* `data-find-field="true"`).
|
|
67
83
|
*/
|
|
68
84
|
export function SearchInput({
|
|
69
85
|
value,
|
|
@@ -74,43 +90,81 @@ export function SearchInput({
|
|
|
74
90
|
inputClassName,
|
|
75
91
|
placeholder = 'Search…',
|
|
76
92
|
'aria-label': ariaLabel,
|
|
93
|
+
clearable = false,
|
|
94
|
+
ref,
|
|
77
95
|
...inputProps
|
|
78
96
|
}: SearchInputProps) {
|
|
79
97
|
const pattern = useMemo(() => parseSearchPattern(value, syntax === false ? PLAIN_ONLY : syntax), [value, syntax]);
|
|
80
98
|
const badge = showSyntaxBadge ? badgeFor(pattern) : null;
|
|
81
99
|
const invalid = pattern.kind === 'invalid';
|
|
100
|
+
const clear = clearable && value !== '';
|
|
101
|
+
const trailing = (badge ? 1 : 0) + (clear ? 1 : 0);
|
|
102
|
+
// Our own handle on the field, so the ✕ can put the caret back — merged with the caller's.
|
|
103
|
+
const own = useRef<HTMLInputElement | null>(null);
|
|
104
|
+
const setRef = (node: HTMLInputElement | null): void => {
|
|
105
|
+
own.current = node;
|
|
106
|
+
if (typeof ref === 'function') ref(node);
|
|
107
|
+
else if (ref) ref.current = node;
|
|
108
|
+
};
|
|
82
109
|
|
|
110
|
+
/*
|
|
111
|
+
* Every element carries BOTH its `cbsi-` class (the plain look, for an app with no Tailwind)
|
|
112
|
+
* and the Tailwind utilities it always had (the look a Tailwind app restyles through
|
|
113
|
+
* `className`/`inputClassName`). The stylesheet's header says why the two cannot fight.
|
|
114
|
+
*/
|
|
83
115
|
return (
|
|
84
|
-
<div className={cn('relative min-w-0', className)}>
|
|
116
|
+
<div className={cn('cbsi relative min-w-0', className)}>
|
|
85
117
|
<Search
|
|
86
118
|
aria-hidden
|
|
87
|
-
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-2.5 size-4 text-muted-foreground'
|
|
119
|
+
className='cbsi-icon -translate-y-1/2 pointer-events-none absolute top-1/2 left-2.5 size-4 text-muted-foreground'
|
|
88
120
|
/>
|
|
89
121
|
<Input
|
|
90
122
|
{...inputProps}
|
|
123
|
+
ref={setRef}
|
|
91
124
|
type='search'
|
|
92
125
|
value={value}
|
|
93
126
|
onChange={(event) => onChange(event.target.value)}
|
|
94
127
|
placeholder={placeholder}
|
|
95
128
|
aria-label={ariaLabel ?? (typeof placeholder === 'string' ? placeholder : 'Search')}
|
|
96
129
|
aria-invalid={invalid || undefined}
|
|
97
|
-
|
|
130
|
+
data-trailing={trailing || undefined}
|
|
131
|
+
data-clearable={clearable || undefined}
|
|
132
|
+
className={cn('cbsi-input pl-8', trailing === 1 && 'pr-9', trailing === 2 && 'pr-16', inputClassName)}
|
|
98
133
|
/>
|
|
99
|
-
{
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
134
|
+
{trailing > 0 ? (
|
|
135
|
+
<span className='cbsi-trail -translate-y-1/2 absolute top-1/2 right-2 inline-flex items-center gap-1'>
|
|
136
|
+
{badge ? (
|
|
137
|
+
<Tooltip content={badge.tip} side='bottom' multiline>
|
|
138
|
+
<span
|
|
139
|
+
role='img'
|
|
140
|
+
aria-label={badge.label}
|
|
141
|
+
data-testid='search-syntax-badge'
|
|
142
|
+
data-kind={pattern.kind}
|
|
143
|
+
className={cn(
|
|
144
|
+
'cbsi-badge inline-flex size-6 items-center justify-center rounded-full',
|
|
145
|
+
invalid ? 'bg-destructive/15 text-destructive' : 'bg-accent/15 text-accent-strong',
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
<badge.icon className='size-3.5' aria-hidden />
|
|
149
|
+
</span>
|
|
150
|
+
</Tooltip>
|
|
151
|
+
) : null}
|
|
152
|
+
{clear ? (
|
|
153
|
+
<button
|
|
154
|
+
// guardrails-ignore no-raw-action-button: a 24px glyph INSIDE the field, styled by
|
|
155
|
+
// this component's own plain CSS (`.cbsi-clear`) so it draws without Tailwind.
|
|
156
|
+
type='button'
|
|
157
|
+
className='cbsi-clear'
|
|
158
|
+
aria-label='Clear search'
|
|
159
|
+
onClick={() => {
|
|
160
|
+
onChange('');
|
|
161
|
+
own.current?.focus();
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
<X aria-hidden />
|
|
165
|
+
</button>
|
|
166
|
+
) : null}
|
|
167
|
+
</span>
|
|
114
168
|
) : null}
|
|
115
169
|
</div>
|
|
116
170
|
);
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SearchInput's own look, as plain CSS — so it draws in an app with NO Tailwind (desk), the way
|
|
3
|
+
* `activityBar.css` and `fileTree.css` let those two travel. Prefix `cbsi-`. Task 2256.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 Everything here is inside `@layer base`, and that is the whole trick — read before editing.
|
|
6
|
+
*
|
|
7
|
+
* The component KEEPS its Tailwind utilities, because a Tailwind app restyles the field through
|
|
8
|
+
* them: flix passes `inputClassName="rounded-full bg-[var(--surface-1)] …"`. An UNLAYERED rule
|
|
9
|
+
* beats every layered one whatever its specificity, and Tailwind v4 puts utilities in
|
|
10
|
+
* `@layer utilities` — so a plain `.cbsi-input { border-radius: … }` would silently undo flix's
|
|
11
|
+
* `rounded-full`. In `@layer base` these rules sit BELOW utilities in every Tailwind app, in
|
|
12
|
+
* either load order: if Tailwind's `@layer theme, base, components, utilities` came first,
|
|
13
|
+
* `base` is second of four; if this file came first, `base` is registered first and Tailwind's
|
|
14
|
+
* statement appends the rest after it. Within `base`, a `.cbsi-` class out-ranks preflight's
|
|
15
|
+
* element selectors, and every value below is the value Tailwind already computes, so a
|
|
16
|
+
* Tailwind app draws exactly what it drew before this file existed.
|
|
17
|
+
*
|
|
18
|
+
* In an app without Tailwind this is the only author layer that matches these elements, so it
|
|
19
|
+
* is the look. Its tokens fall back to the plainer ones an app is most likely to bridge
|
|
20
|
+
* (`--border`, `--background`, `--foreground`, `--muted-foreground`, `--ring`, `--accent`,
|
|
21
|
+
* `--destructive`, `--radius-sm`) — desk answers those on `.cs-root`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
@layer base {
|
|
25
|
+
.cbsi {
|
|
26
|
+
position: relative;
|
|
27
|
+
min-width: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.cbsi-icon {
|
|
31
|
+
position: absolute;
|
|
32
|
+
top: 50%;
|
|
33
|
+
left: 0.625rem;
|
|
34
|
+
translate: 0 -50%;
|
|
35
|
+
width: 1rem;
|
|
36
|
+
height: 1rem;
|
|
37
|
+
pointer-events: none;
|
|
38
|
+
color: var(--muted-foreground);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* The field — `fieldControlClass('md')`, written out. */
|
|
42
|
+
.cbsi-input {
|
|
43
|
+
box-sizing: border-box;
|
|
44
|
+
width: 100%;
|
|
45
|
+
height: var(--control-h-md, 36px);
|
|
46
|
+
margin: 0;
|
|
47
|
+
padding: 0 0.75rem 0 2rem;
|
|
48
|
+
border: 1px solid var(--field-border, var(--border));
|
|
49
|
+
border-radius: var(--field-radius, var(--radius-md, var(--radius-sm, 0.375rem)));
|
|
50
|
+
background-color: var(--field-bg, var(--background));
|
|
51
|
+
color: var(--field-fg, var(--foreground));
|
|
52
|
+
font: inherit;
|
|
53
|
+
font-size: 1rem;
|
|
54
|
+
line-height: 1.5rem;
|
|
55
|
+
outline: none;
|
|
56
|
+
appearance: none;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* 16px on a phone keeps iOS from zooming on focus; 14px from `sm:` up, as `text-sm`. */
|
|
60
|
+
@media (min-width: 40rem) {
|
|
61
|
+
.cbsi-input {
|
|
62
|
+
font-size: 0.875rem;
|
|
63
|
+
line-height: 1.25rem;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* DESIGN.md §5: 44px to a thumb. */
|
|
68
|
+
@media (pointer: coarse) {
|
|
69
|
+
.cbsi-input {
|
|
70
|
+
min-height: 2.75rem;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.cbsi-input::placeholder {
|
|
75
|
+
color: var(--field-placeholder, var(--muted-foreground));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.cbsi-input:focus-visible {
|
|
79
|
+
border-color: var(--field-ring, var(--ring));
|
|
80
|
+
box-shadow: 0 0 0 2px color-mix(in oklab, var(--field-ring, var(--ring)) 30%, transparent);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.cbsi-input[aria-invalid='true'] {
|
|
84
|
+
border-color: var(--field-error, var(--destructive));
|
|
85
|
+
box-shadow: 0 0 0 2px color-mix(in oklab, var(--field-error, var(--destructive)) 30%, transparent);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.cbsi-input:disabled {
|
|
89
|
+
cursor: not-allowed;
|
|
90
|
+
opacity: 0.5;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Room on the right for whatever sits there: a badge, a clear button, or both. */
|
|
94
|
+
.cbsi-input[data-trailing='1'] {
|
|
95
|
+
padding-right: 2.25rem;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.cbsi-input[data-trailing='2'] {
|
|
99
|
+
padding-right: 4rem;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* A clearable box draws its own ✕, so the browser's must not draw a second one. */
|
|
103
|
+
.cbsi-input[data-clearable]::-webkit-search-cancel-button {
|
|
104
|
+
appearance: none;
|
|
105
|
+
display: none;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.cbsi-trail {
|
|
109
|
+
position: absolute;
|
|
110
|
+
top: 50%;
|
|
111
|
+
right: 0.5rem;
|
|
112
|
+
translate: 0 -50%;
|
|
113
|
+
display: inline-flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
gap: 0.25rem;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* The badge: a filled capsule (DESIGN.md §3), accent while a syntax is in play. */
|
|
119
|
+
.cbsi-badge {
|
|
120
|
+
display: inline-flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
justify-content: center;
|
|
123
|
+
width: 1.5rem;
|
|
124
|
+
height: 1.5rem;
|
|
125
|
+
border-radius: 9999px;
|
|
126
|
+
background-color: color-mix(in oklab, var(--accent) 15%, transparent);
|
|
127
|
+
color: var(--accent-strong, var(--accent));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.cbsi-badge[data-kind='invalid'] {
|
|
131
|
+
background-color: color-mix(in oklab, var(--destructive) 15%, transparent);
|
|
132
|
+
color: var(--destructive);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.cbsi-badge svg {
|
|
136
|
+
width: 0.875rem;
|
|
137
|
+
height: 0.875rem;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.cbsi-clear {
|
|
141
|
+
position: relative;
|
|
142
|
+
display: inline-flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
justify-content: center;
|
|
145
|
+
width: 1.5rem;
|
|
146
|
+
height: 1.5rem;
|
|
147
|
+
margin: 0;
|
|
148
|
+
padding: 0;
|
|
149
|
+
border: 0;
|
|
150
|
+
border-radius: var(--radius-sm, 0.375rem);
|
|
151
|
+
background: transparent;
|
|
152
|
+
color: var(--muted-foreground);
|
|
153
|
+
cursor: pointer;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.cbsi-clear:hover {
|
|
157
|
+
color: var(--foreground);
|
|
158
|
+
background: var(--muted, color-mix(in oklab, var(--foreground) 8%, transparent));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.cbsi-clear:focus-visible {
|
|
162
|
+
outline: 2px solid var(--ring);
|
|
163
|
+
outline-offset: 1px;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* The ✕ is 24px to the eye; to a thumb it reaches the 44px floor (DESIGN.md §5). */
|
|
167
|
+
@media (pointer: coarse) {
|
|
168
|
+
.cbsi-clear::after {
|
|
169
|
+
content: '';
|
|
170
|
+
position: absolute;
|
|
171
|
+
inset: -10px;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.cbsi-clear svg {
|
|
176
|
+
width: 0.75rem;
|
|
177
|
+
height: 0.75rem;
|
|
178
|
+
}
|
|
179
|
+
}
|