@estiva-app/ui 0.16.0 → 0.16.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -76,10 +76,16 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
76
76
  - **`pending` while rows are still on their way; `empty` only once none are.**
77
77
  Pass `empty` while a search is running and the page says "nothing" before it
78
78
  knows.
79
- - **Rows that arrive late do not take the highlight.** Base UI keeps the
80
- highlight's position rather than its row, so a group arriving above the lit
81
- row would hand the highlight and Enter to something else. The palette
82
- walks it back to the row you had. Typing starts again from the first row.
79
+ - **`notes` are what a person should know about the rows and cannot act on** —
80
+ "2 more where you cannot open them", "the search was refused, so this list
81
+ is incomplete". Quiet lines under the rows, never rows: the arrows do not stop
82
+ on them.
83
+ - **Rows that arrive late do not take the highlight you moved to.** Base UI keeps
84
+ the highlight's position rather than its row, so a group arriving above the
85
+ lit row would hand the highlight — and Enter — to something else. Once you have
86
+ moved the highlight on a level, the palette walks it back to the row you had.
87
+ Until you have, the first row is lit, whichever row that becomes. Typing starts
88
+ again from the first row.
83
89
  - **Home and End move the text cursor, and nothing else.** Base UI would also
84
90
  send the highlight to the first or last row; the palette stops that.
85
91
  - **The field is named by its placeholder**, so say what it asks for.
@@ -117,7 +123,7 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
117
123
 
118
124
  **`CommandPaletteSearch`** — `query` and `onQueryChange` (the field, controlled),
119
125
  `placeholder` (also the field's name), `groups` (`{ label, rows }`, a group
120
- with no rows is not drawn), `chip?` (`{ label, leading?, onBack }`), `pending?`,
126
+ with no rows is not drawn), `chip?` (`{ label, leading?, onBack }`), `pending?`, `notes?`,
121
127
  `empty?`, and `children` for what sits above the rows.
122
128
 
123
129
  **A row** — `id`, `label`, `description?`, `icon?` or `leading?`, `onSelect`,
@@ -365,6 +365,8 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
365
365
  : undefined
366
366
  }
367
367
  pending={level.kind === 'first' && searching ? 'Searching…' : undefined}
368
+ // What the later group left out: a line to read, not a row to pick.
369
+ notes={level.kind === 'first' && q && !searching && LATER.some((l) => l.toLowerCase().includes(q)) ? ['1 more in a place you cannot open.'] : undefined}
368
370
  empty={
369
371
  level.kind === 'first' && q && !searching
370
372
  ? `Nothing is called “${q}”.`
@@ -388,7 +390,7 @@ const story = (start: Start): Story => ({
388
390
  /** The first level: rows in groups, the first one lit. ↑ ↓ stop at the ends, Tab goes into a place, Ctrl+Backspace forgets a recent row. */
389
391
  export const FirstLevel: Story = story('first')
390
392
 
391
- /** Typing: the rows that match now, a line while a later group is on its way, and that group arriving without moving the lit row. */
393
+ /** Typing: the rows that match now, a line while a later group is on its way, that group arriving without moving the lit row, and a note about what it left out. */
392
394
  export const WhileRowsArrive: Story = story('arriving')
393
395
 
394
396
  /** Nothing matches: the empty line, where the first row would be. */
@@ -39,6 +39,7 @@ function Search({
39
39
  onOpenChange = () => {},
40
40
  modKey,
41
41
  pending,
42
+ notes,
42
43
  empty,
43
44
  }: {
44
45
  groups: CommandPaletteGroup[]
@@ -47,12 +48,13 @@ function Search({
47
48
  onOpenChange?: (open: boolean) => void
48
49
  modKey?: string
49
50
  pending?: string
51
+ notes?: string[]
50
52
  empty?: string
51
53
  }) {
52
54
  const [query, setQuery] = useState(initialQuery)
53
55
  return (
54
56
  <CommandPalette open onOpenChange={onOpenChange} label="Palette" where="In Item one" modKey={modKey}>
55
- <CommandPaletteSearch query={query} onQueryChange={setQuery} placeholder="Search" groups={groups} chip={chip} pending={pending} empty={empty} />
57
+ <CommandPaletteSearch query={query} onQueryChange={setQuery} placeholder="Search" groups={groups} chip={chip} pending={pending} notes={notes} empty={empty} />
56
58
  </CommandPalette>
57
59
  )
58
60
  }
@@ -201,6 +203,17 @@ describe('CommandPaletteSearch — rows', () => {
201
203
  await waitFor(() => expect(lit()).toBe('Two'))
202
204
  })
203
205
 
206
+ it('lets rows arriving above take the first place while the highlight has not been moved', async () => {
207
+ const early: CommandPaletteGroup = { label: 'Early', rows: [row('One'), row('Two')] }
208
+ const late: CommandPaletteGroup = { label: 'Late', rows: [row('Late one')] }
209
+ const { rerender } = render(<Search groups={[early]} />)
210
+ await focusedField()
211
+ await waitFor(() => expect(lit()).toBe('One'))
212
+ rerender(<Search groups={[late, early]} />)
213
+ await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))
214
+ await waitFor(() => expect(lit()).toBe('Late one'))
215
+ })
216
+
204
217
  it('names in the footer only the keys that work on the lit row', async () => {
205
218
  const user = userEvent.setup()
206
219
  render(
@@ -232,6 +245,16 @@ describe('CommandPaletteSearch — rows', () => {
232
245
  expect(await screen.findByText('Nothing here yet.')).toBeTruthy()
233
246
  })
234
247
 
248
+ it('draws notes as lines under the rows, which the arrows do not stop on', async () => {
249
+ const user = userEvent.setup()
250
+ render(<Search groups={[{ label: 'Group', rows: [row('One'), row('Two')] }]} notes={['2 more you cannot open.']} />)
251
+ await focusedField()
252
+ expect(await screen.findByText('2 more you cannot open.')).toBeTruthy()
253
+ expect(screen.getAllByRole('option')).toHaveLength(2)
254
+ await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}')
255
+ expect(lit()).toBe('Two')
256
+ })
257
+
235
258
  it('says rows are on their way', async () => {
236
259
  render(<Search groups={[{ label: 'Group', rows: [row('One')] }]} pending="Searching…" />)
237
260
  expect(await screen.findByText('Searching…')).toBeTruthy()
@@ -221,6 +221,13 @@ export interface CommandPaletteSearchProps {
221
221
  chip?: CommandPaletteChip
222
222
  /** A line under the rows while more are on their way — "Searching…". */
223
223
  pending?: string
224
+ /**
225
+ * Quiet lines under the rows, for what a person should know about them and
226
+ * cannot act on — "2 more where you cannot open them", "the search was
227
+ * refused, so this list is incomplete". Plain lines, never rows: the arrows
228
+ * do not stop on them.
229
+ */
230
+ notes?: string[]
224
231
  /** The line when there are no rows. Leave it out while rows are still on their way. */
225
232
  empty?: string
226
233
  /** Above the rows: a `CommandPaletteWorking`, `CommandPaletteAnswer` or `CommandPaletteQuote`. */
@@ -229,7 +236,7 @@ export interface CommandPaletteSearchProps {
229
236
 
230
237
  type ListGroup = { value: string; items: CommandPaletteRow[] }
231
238
 
232
- export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups, chip, pending, empty, children }: CommandPaletteSearchProps) {
239
+ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups, chip, pending, notes = [], empty, children }: CommandPaletteSearchProps) {
233
240
  const { modKey, popupRef } = usePalette('CommandPaletteSearch')
234
241
  const back = useBack(chip, popupRef)
235
242
  const items = useMemo<ListGroup[]>(() => groups.filter((g) => g.rows.length > 0).map((g) => ({ value: g.label, items: g.rows })), [groups])
@@ -254,14 +261,22 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
254
261
  arrow keys, exactly as a person would. It does so only when Base UI moved
255
262
  the highlight by itself ("none") — never after an arrow, the pointer, or
256
263
  typing, which starts again from the first row.
264
+
265
+ And only once the person has moved the highlight on this level. Until then
266
+ the lit row is simply the first one, and a row that arrives above it — a
267
+ thread's own rows, a moment after opening — becomes the first one.
257
268
  */
258
269
  const inputRef = useRef<HTMLInputElement>(null)
259
270
  const reported = useRef<{ id?: string; reason?: string }>({})
260
271
  const settled = useRef<{ id?: string; query: string; level?: string } | null>(null)
272
+ /** The person has moved the highlight since the text or the level last changed. */
273
+ const moved = useRef(false)
261
274
  useLayoutEffect(() => {
262
275
  const before = settled.current
263
276
  const now = reported.current
264
- if (before?.id && now.reason === 'none' && now.id !== before.id && before.query === query && before.level === chip?.label) {
277
+ const sameLevel = before?.query === query && before?.level === chip?.label
278
+ if (!sameLevel) moved.current = false
279
+ if (moved.current && before?.id && now.reason === 'none' && now.id !== before.id && sameLevel) {
265
280
  const ids = rows.map((r) => r.id)
266
281
  const want = ids.indexOf(before.id)
267
282
  const at = now.id === undefined ? -1 : ids.indexOf(now.id)
@@ -347,6 +362,7 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
347
362
  onItemHighlighted={(row, details) => {
348
363
  const id = (row as CommandPaletteRow | undefined)?.id
349
364
  reported.current = { id, reason: details.reason }
365
+ if (details.reason !== 'none') moved.current = true
350
366
  setLitId(id)
351
367
  }}
352
368
  >
@@ -410,6 +426,11 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
410
426
  </>
411
427
  )}
412
428
  </Autocomplete.Status>
429
+ {notes.map((note) => (
430
+ <div key={note} className="flex min-h-8 items-center px-3">
431
+ <FieldLine>{note}</FieldLine>
432
+ </div>
433
+ ))}
413
434
  <Autocomplete.Empty className="flex items-center px-3 [&:not(:empty)]:min-h-10">
414
435
  {empty && <EmptyState scope="section" message={empty} />}
415
436
  </Autocomplete.Empty>