@estiva-app/ui 0.14.0 → 0.16.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 (56) hide show
  1. package/README.md +28 -0
  2. package/dist/Breadcrumb.d.ts.map +1 -1
  3. package/dist/ChipInput.d.ts +14 -1
  4. package/dist/ChipInput.d.ts.map +1 -1
  5. package/dist/CommandPalette.d.ts +114 -0
  6. package/dist/CommandPalette.d.ts.map +1 -0
  7. package/dist/Menu.d.ts +4 -0
  8. package/dist/Menu.d.ts.map +1 -1
  9. package/dist/Toast.d.ts.map +1 -1
  10. package/dist/eslint/escape.d.ts +61 -0
  11. package/dist/eslint/escape.d.ts.map +1 -0
  12. package/dist/eslint/has-a-page-and-a-story.d.ts +4 -0
  13. package/dist/eslint/has-a-page-and-a-story.d.ts.map +1 -0
  14. package/dist/eslint/index.d.ts +59 -0
  15. package/dist/eslint/index.d.ts.map +1 -0
  16. package/dist/eslint/index.js +286 -0
  17. package/dist/eslint/index.js.map +7 -0
  18. package/dist/eslint/no-hand-rolled-behaviour.d.ts +3 -0
  19. package/dist/eslint/no-hand-rolled-behaviour.d.ts.map +1 -0
  20. package/dist/eslint/no-raw-button.d.ts +11 -0
  21. package/dist/eslint/no-raw-button.d.ts.map +1 -0
  22. package/dist/eslint/raw-element-outside-a-wrapper.d.ts +29 -0
  23. package/dist/eslint/raw-element-outside-a-wrapper.d.ts.map +1 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +614 -292
  27. package/dist/index.js.map +4 -4
  28. package/package.json +9 -1
  29. package/src/Avatar.tsx +1 -1
  30. package/src/AvatarGroup.tsx +1 -1
  31. package/src/Breadcrumb.tsx +6 -2
  32. package/src/ChipInput.mdx +9 -0
  33. package/src/ChipInput.stories.tsx +18 -0
  34. package/src/ChipInput.test.tsx +18 -0
  35. package/src/ChipInput.tsx +18 -4
  36. package/src/CommandPalette.mdx +133 -0
  37. package/src/CommandPalette.stories.tsx +416 -0
  38. package/src/CommandPalette.test.tsx +392 -0
  39. package/src/CommandPalette.tsx +643 -0
  40. package/src/Menu.tsx +4 -2
  41. package/src/Select.test.tsx +20 -3
  42. package/src/Toast.tsx +12 -24
  43. package/src/eslint/escape.ts +112 -0
  44. package/src/eslint/has-a-page-and-a-story.test.ts +57 -0
  45. package/src/eslint/has-a-page-and-a-story.ts +97 -0
  46. package/src/eslint/index.test.ts +114 -0
  47. package/src/eslint/index.ts +145 -0
  48. package/src/eslint/no-hand-rolled-behaviour.test.ts +70 -0
  49. package/src/eslint/no-hand-rolled-behaviour.ts +116 -0
  50. package/src/eslint/no-raw-button.test.ts +118 -0
  51. package/src/eslint/no-raw-button.ts +39 -0
  52. package/src/eslint/raw-element-outside-a-wrapper.test.ts +82 -0
  53. package/src/eslint/raw-element-outside-a-wrapper.ts +85 -0
  54. package/src/index.ts +17 -0
  55. package/stories/Choosing.mdx +1 -0
  56. package/stories/TokensPage.tsx +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
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",
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/index.d.ts",
20
20
  "default": "./dist/index.js"
21
21
  },
22
+ "./eslint": {
23
+ "types": "./dist/eslint/index.d.ts",
24
+ "default": "./dist/eslint/index.js"
25
+ },
22
26
  "./tailwind-preset": "./tailwind-preset.js",
23
27
  "./tokens.css": "./tokens.css",
24
28
  "./package.json": "./package.json",
@@ -32,7 +36,11 @@
32
36
  "test:watch": "vitest --project unit",
33
37
  "test:a11y": "vitest run --project storybook-signal --project storybook-ship",
34
38
  "typecheck": "tsc --noEmit",
39
+ "prelint": "node build.mjs",
35
40
  "lint": "eslint .",
41
+ "prelint:rules": "node build.mjs",
42
+ "lint:rules": "eslint --config eslint.gates.config.js .",
43
+ "postlint:rules": "node scripts/gates-count.mjs",
36
44
  "gates:status": "node scripts/gates-status.mjs",
37
45
  "prepublishOnly": "npm run build"
38
46
  },
package/src/Avatar.tsx CHANGED
@@ -118,7 +118,7 @@ export function Avatar({ src, name, alt = '', size = 36, label: spoken, classNam
118
118
  */
119
119
  className="w-full h-full flex items-center justify-center font-semibold leading-none"
120
120
  style={{
121
- /* eslint-disable no-restricted-syntax -- the per-person palette (the note at
121
+ /* eslint-disable no-restricted-syntax -- @estiva-escape: the per-person palette (the note at
122
122
  the top): eight hues picked from the name, the one ink that reads on all
123
123
  of them, and a size that follows `size`. None of it can be a token. */
124
124
  // The one ink colour that reads on all eight hues, which are a
@@ -55,7 +55,7 @@ export function AvatarGroup({ members, size = 24 }: AvatarGroupProps) {
55
55
  <span
56
56
  key={i}
57
57
  className="relative flex rounded-sm"
58
- /* eslint-disable-next-line no-restricted-syntax -- the ring's width is the `ring` prop, so it cannot be a class; its colour is the surface token */
58
+ /* eslint-disable-next-line no-restricted-syntax -- @estiva-escape: the ring's width is the `ring` prop, so it cannot be a class; its colour is the surface token */
59
59
  style={{ marginRight: -overlap, boxShadow: `0 0 0 ${ring}px var(--bg-surface)` }}
60
60
  >
61
61
  {/* Each face says whose it is: a stack stands on its own, with no
@@ -1,5 +1,6 @@
1
1
  import { Fragment, useCallback, useLayoutEffect, useRef, useState, type MouseEventHandler, type ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
+ import { Link } from './Link'
3
4
  import { WithTooltip } from './Tooltip'
4
5
 
5
6
  /**
@@ -88,9 +89,12 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
88
89
  labelRefs.current[index] = el
89
90
  }
90
91
  const crumb = item.href ? (
91
- <a ref={setLabelRef} href={item.href} onClick={item.onClick} className={cn(text, 'hover:text-text-primary')}>
92
+ // `plain` adds no look of its own: a crumb keeps the trail's size and
93
+ // tone, and brightens on hover. The package's own Link, so a trail
94
+ // follows whatever a link learns to do (UIG-5).
95
+ <Link ref={setLabelRef} variant="plain" href={item.href} onClick={item.onClick} className={cn(text, 'hover:text-text-primary')}>
92
96
  {item.label}
93
- </a>
97
+ </Link>
94
98
  ) : (
95
99
  <span ref={setLabelRef} className={text} aria-current={last ? 'page' : undefined}>
96
100
  {item.label}
package/src/ChipInput.mdx CHANGED
@@ -65,6 +65,15 @@ import { ChipInput } from '@estiva-app/ui'
65
65
  <ChipInput aria-labelledby="to-label" value={chosen} onChange={setChosen} options={directory} />
66
66
  ```
67
67
 
68
+ - **`InputChip` on its own** names its ✕ `Remove <label>`. Where the ✕ does
69
+ something else, say what with `removeLabel` (a scope chip: "Leave Ship").
70
+ - To cap a long label, give the chip a `max-w-*` and `truncate`: the label is
71
+ cut and the ✕ keeps its size. `truncate` is off unless asked, because
72
+ cutting clips a letter's soft edge by up to 4px at 1x even when the label
73
+ fits.
74
+
75
+ <Canvas of={ChipInputStories.ALongLabelCut} />
76
+
68
77
  ## Keys
69
78
 
70
79
  | Input | What happens |
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { useState } from 'react'
3
+ import { IconSquareRounded } from '@tabler/icons-react'
3
4
  import { Avatar } from './Avatar'
4
5
  import { ChipInput, InputChip, type ChipInputOption } from './ChipInput'
5
6
 
@@ -97,3 +98,20 @@ export const TheChipItself: Story = {
97
98
  </div>
98
99
  ),
99
100
  }
101
+
102
+ /** A chip capped in width, its long label cut, and a ✕ named for what it does. */
103
+ export const ALongLabelCut: Story = {
104
+ parameters: { controls: { disable: true } },
105
+ render: () => (
106
+ <div className="flex items-center gap-2">
107
+ <InputChip
108
+ label="A label much longer than the chip may be"
109
+ leading={<IconSquareRounded size={16} stroke={1.5} className="text-text-secondary" />}
110
+ onRemove={() => {}}
111
+ removeLabel="Leave the label"
112
+ truncate
113
+ className="max-w-44"
114
+ />
115
+ </div>
116
+ ),
117
+ }
@@ -215,4 +215,22 @@ describe('InputChip', () => {
215
215
  render(<InputChip label="Label" />)
216
216
  expect(screen.queryByRole('button')).toBeNull()
217
217
  })
218
+
219
+ it('names its ✕ with removeLabel when given', async () => {
220
+ const user = userEvent.setup()
221
+ const onRemove = vi.fn()
222
+ render(<InputChip label="Label" onRemove={onRemove} removeLabel="Leave Label" />)
223
+ await user.click(screen.getByRole('button', { name: 'Leave Label' }))
224
+ expect(onRemove).toHaveBeenCalledTimes(1)
225
+ expect(screen.queryByRole('button', { name: 'Remove Label' })).toBeNull()
226
+ })
227
+
228
+ it('cuts its label only when asked, and then the ✕ never gives way', () => {
229
+ const { rerender } = render(<InputChip label="Label" onRemove={() => {}} />)
230
+ expect(screen.getByText('Label').className).not.toMatch(/\btruncate\b/)
231
+ expect(screen.getByRole('button').className).not.toMatch(/\bshrink-0\b/)
232
+ rerender(<InputChip label="Label" onRemove={() => {}} truncate className="max-w-[160px]" />)
233
+ expect(screen.getByText('Label').className).toMatch(/\bmin-w-0\b.*\btruncate\b/)
234
+ expect(screen.getByRole('button').className).toMatch(/\bshrink-0\b/)
235
+ })
218
236
  })
package/src/ChipInput.tsx CHANGED
@@ -38,14 +38,27 @@ export interface InputChipProps {
38
38
  leading?: ReactNode
39
39
  /** Draws the ✕; absent, the chip is display-only. */
40
40
  onRemove?: () => void
41
+ /**
42
+ * The ✕'s name for a screen reader. `Remove <label>` when not given. A chip
43
+ * whose ✕ does something other than remove it names what it does — a
44
+ * launcher's scope chip leaves the scope ("Leave Ship").
45
+ */
46
+ removeLabel?: string
47
+ /**
48
+ * Cut a long label with an ellipsis once the chip is capped, by a
49
+ * `max-w-*` on `className`. Off unless asked: cutting clips up to 4px of a
50
+ * letter's soft edge at 1x even when the label fits (measured 2026-09-15),
51
+ * so a chip that is never capped keeps every pixel.
52
+ */
53
+ truncate?: boolean
41
54
  className?: string
42
55
  }
43
56
 
44
- export function InputChip({ label, leading, onRemove, className }: InputChipProps) {
57
+ export function InputChip({ label, leading, onRemove, removeLabel, truncate, className }: InputChipProps) {
45
58
  return (
46
59
  <div className={cn(CHIP_BOX, chipPadding(!!leading, !!onRemove), className)}>
47
60
  {leading && <span className="flex shrink-0 items-center">{leading}</span>}
48
- <span className={CHIP_LABEL}>{label}</span>
61
+ <span className={cn(CHIP_LABEL, truncate && 'min-w-0 truncate')}>{label}</span>
49
62
  {/* Base UI's `Button`, as every button in the package is (D6). Base UI
50
63
  has no chip of its own — its only chips are `Combobox.Chip` and
51
64
  `ChipRemove`, which throw outside a combobox — so the ✕ is the one
@@ -57,8 +70,9 @@ export function InputChip({ label, leading, onRemove, className }: InputChipProp
57
70
  e.stopPropagation()
58
71
  onRemove()
59
72
  }}
60
- className={CHIP_REMOVE}
61
- aria-label={`Remove ${label}`}
73
+ // In a capped chip the label gives way, never the ✕.
74
+ className={cn(CHIP_REMOVE, truncate && 'shrink-0')}
75
+ aria-label={removeLabel ?? `Remove ${label}`}
62
76
  >
63
77
  <IconX size={10} stroke={1.5} />
64
78
  </BaseButton>
@@ -0,0 +1,133 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as CommandPaletteStories from './CommandPalette.stories'
3
+
4
+ <Meta of={CommandPaletteStories} />
5
+
6
+ # CommandPalette
7
+
8
+ A window that searches and runs things: a field, rows in groups, and a footer
9
+ naming the keys that work right now. It shows one level at a time — rows, or a
10
+ form — and the app keeps the levels.
11
+
12
+ <Canvas of={CommandPaletteStories.FirstLevel} />
13
+
14
+ ## When
15
+
16
+ - **One place to find something and act on it**, from anywhere in an app,
17
+ opened by a shortcut the app binds.
18
+ - **`CommandPaletteSearch`** — a level of rows. Without a `chip` it is the
19
+ first level; with one, you are inside something, and the chip names it.
20
+ - **`CommandPaletteForm`** — a level that is a form: what a row creates or
21
+ changes, without leaving the palette.
22
+ - **`CommandPaletteWorking`, `CommandPaletteAnswer`, `CommandPaletteQuote`** —
23
+ above the rows, for a result that is written rather than listed: a line while
24
+ it is on its way, an answer with marks pointing at the rows it came from, and
25
+ text written to be used somewhere else.
26
+
27
+ <Canvas of={CommandPaletteStories.Answer} />
28
+
29
+ <Canvas of={CommandPaletteStories.Form} />
30
+
31
+ ## When not
32
+
33
+ - A short list of verbs from a trigger → **Menu**.
34
+ - One value for a field → **Select**; several → **ChipInput**.
35
+ - A form with a title and buttons of its own, not reached from a palette →
36
+ **DialogShell**. The palette is not built on it: a palette has no title bar,
37
+ no close button and no button row, and on the shell it would be the shell with
38
+ every slot switched off.
39
+ - Filtering a list that stays on the page → **SearchInput**.
40
+
41
+ ## How
42
+
43
+ ```tsx
44
+ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
45
+
46
+ <CommandPalette open={open} onOpenChange={setOpen} label="Command palette" where="In Item one" modKey="Ctrl">
47
+ <CommandPaletteSearch
48
+ query={query}
49
+ onQueryChange={setQuery}
50
+ placeholder="Search, or choose a place"
51
+ groups={[
52
+ { label: 'Recent', rows: [{ id: 'item-one', label: 'Item one', icon: <IconSquareRounded size={16} stroke={1.5} />, onSelect: open, onForget: forget }] },
53
+ { label: 'Places', rows: [{ id: 'place-one', label: 'Place one', icon, onSelect: goIn, onGoIn: goIn }] },
54
+ ]}
55
+ />
56
+ </CommandPalette>
57
+ ```
58
+
59
+ - **The app opens it.** The palette listens to nothing outside its own window:
60
+ bind the shortcut in the app, pass `open`, and give `modKey` the platform's
61
+ spelling (`Cmd` on a Mac). The keys answer to Ctrl and Cmd either way.
62
+ - **One level at a time, and the app keeps the stack.** Render a
63
+ `CommandPaletteSearch` or a `CommandPaletteForm` as the only child, and keep
64
+ each level's query yourself, so going back puts back the text that was there.
65
+ A level inside another passes `chip` — its label, and `onBack`.
66
+ - **A row says what it can do, and the keys and the footer follow.** `onSelect`
67
+ is Enter and a click. `onGoIn` makes Tab, and → at the end of the text, go in,
68
+ and draws the chevron. `onForget` makes Ctrl+Backspace forget it — for rows
69
+ that are the person's own history. The footer names only what the lit row
70
+ answers to.
71
+ - **`icon` sits on the row's 32px tile; `leading` takes the same 32px with no
72
+ tile**, for something with a look of its own — a face, a status mark.
73
+ - **`id` is unique in the level.** The lit row is followed by it, so a caller
74
+ that builds its rows again on every render still runs the handler of the row
75
+ on screen.
76
+ - **`pending` while rows are still on their way; `empty` only once none are.**
77
+ Pass `empty` while a search is running and the page says "nothing" before it
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.
83
+ - **Home and End move the text cursor, and nothing else.** Base UI would also
84
+ send the highlight to the first or last row; the palette stops that.
85
+ - **The field is named by its placeholder**, so say what it asks for.
86
+ - **A form's fields go in `Field`s.** Mark the missing ones with `error` inside
87
+ `onSubmit`, and the first of them takes focus. `working` locks the fields,
88
+ puts its words on the button and beside it, and holds focus on the form so
89
+ Esc still closes it; when it clears, focus goes to the first field that needs
90
+ something, or back where it was. `error` is what went wrong, beside the
91
+ button. `submitWaits` says why there is nothing to submit yet, on the button,
92
+ and Ctrl+Enter does nothing until it clears.
93
+ - **Focus never falls out of a level.** When the element that had it goes — a
94
+ row picked, a level swapped — focus goes to the level's first control.
95
+
96
+ ## Keys
97
+
98
+ | Where | Input | What happens |
99
+ |---|---|---|
100
+ | a level of rows | ↑ / ↓ | move between rows, and stop at the ends |
101
+ | | Enter, or a click | what the lit row does |
102
+ | | Tab | goes in, on a row that leads somewhere; anywhere else nothing, and focus stays in the field |
103
+ | | → at the end of the text | goes in, on a row that leads somewhere; anywhere else it moves the cursor |
104
+ | | Ctrl+Backspace | forgets the lit row, if it can be forgotten; otherwise deletes a word |
105
+ | | Home / End | move the text cursor; the lit row stays |
106
+ | inside a level | Backspace at the start of the field, or the chip's ✕ | back, with focus in the field |
107
+ | a form | Tab / Shift+Tab | between the fields, never out of the window |
108
+ | | Ctrl+Enter | submits; if a field needs something, focus goes to the first |
109
+ | | Backspace in an empty text field | back. In a form that is one list, Backspace anywhere; in a list inside a form with text fields, nothing |
110
+ | | while working | nothing but Esc |
111
+ | | Esc in an open list | closes the list first |
112
+ | anywhere | Esc, or a click outside | closes; focus returns to whatever opened it |
113
+
114
+ ## Props
115
+
116
+ <Controls of={CommandPaletteStories.FirstLevel} />
117
+
118
+ **`CommandPaletteSearch`** — `query` and `onQueryChange` (the field, controlled),
119
+ `placeholder` (also the field's name), `groups` (`{ label, rows }`, a group
120
+ with no rows is not drawn), `chip?` (`{ label, leading?, onBack }`), `pending?`,
121
+ `empty?`, and `children` for what sits above the rows.
122
+
123
+ **A row** — `id`, `label`, `description?`, `icon?` or `leading?`, `onSelect`,
124
+ `onGoIn?`, `onForget?`.
125
+
126
+ **`CommandPaletteForm`** — `chip`, `icon?`, `submitLabel`, `onSubmit`,
127
+ `submitWaits?`, `working?` (`{ button, line }`), `error?`, and the fields as
128
+ `children`.
129
+
130
+ **`CommandPaletteWorking`** — the words as `children`, `bars?` (0–3, default 2).
131
+ **`CommandPaletteAnswer`** — the text as `children` (a blank line starts a
132
+ paragraph; `[1]` is a mark), `note?`. **`CommandPaletteQuote`** — the text as
133
+ `children`, line breaks kept.