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.
Files changed (33) hide show
  1. package/dist/react/components/SearchInput.d.ts +15 -1
  2. package/dist/react/components/SearchInput.d.ts.map +1 -1
  3. package/dist/react/components/SearchInput.js +36 -4
  4. package/dist/react/components/SearchInput.js.map +1 -1
  5. package/dist/react/components/searchInput.css +179 -0
  6. package/dist/react/hooks/useScrollMemory.d.ts +3 -1
  7. package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
  8. package/dist/react/hooks/useScrollMemory.js +66 -47
  9. package/dist/react/hooks/useScrollMemory.js.map +1 -1
  10. package/dist/react/media/DenseGalleryShell.d.ts +8 -2
  11. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  12. package/dist/react/media/DenseGalleryShell.js +2 -2
  13. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  14. package/dist/react/media-gallery/GalleryTable.js +1 -1
  15. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  16. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  17. package/dist/react/media-gallery/MediaGallery.js +16 -4
  18. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  19. package/dist/react/media-gallery/galleryReturn.d.ts +50 -0
  20. package/dist/react/media-gallery/galleryReturn.d.ts.map +1 -0
  21. package/dist/react/media-gallery/galleryReturn.js +221 -0
  22. package/dist/react/media-gallery/galleryReturn.js.map +1 -0
  23. package/package.json +8 -1
  24. package/src/demoFixture.spec.ts +4 -2
  25. package/src/react/components/SearchInput.spec.tsx +57 -0
  26. package/src/react/components/SearchInput.tsx +74 -20
  27. package/src/react/components/searchInput.css +179 -0
  28. package/src/react/hooks/useScrollMemory.spec.tsx +38 -0
  29. package/src/react/hooks/useScrollMemory.ts +82 -47
  30. package/src/react/media/DenseGalleryShell.tsx +9 -1
  31. package/src/react/media-gallery/GalleryTable.tsx +2 -0
  32. package/src/react/media-gallery/MediaGallery.tsx +19 -1
  33. 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":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.22.0",
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",
@@ -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
- const file = url === '/' ? 'index.html' : url.replace(/^\//, '');
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
- className={cn('pl-8', badge && 'pr-9', inputClassName)}
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
- {badge ? (
100
- <Tooltip content={badge.tip} side='bottom' multiline>
101
- <span
102
- role='img'
103
- aria-label={badge.label}
104
- data-testid='search-syntax-badge'
105
- data-kind={pattern.kind}
106
- className={cn(
107
- '-translate-y-1/2 absolute top-1/2 right-2 inline-flex size-6 items-center justify-center rounded-full',
108
- invalid ? 'bg-destructive/15 text-destructive' : 'bg-accent/15 text-accent-strong',
109
- )}
110
- >
111
- <badge.icon className='size-3.5' aria-hidden />
112
- </span>
113
- </Tooltip>
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
+ }