@estiva-app/ui 0.23.0 → 0.24.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 (98) hide show
  1. package/dist/AppShell.d.ts.map +1 -1
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Form.d.ts.map +1 -1
  4. package/dist/IconButton.d.ts +9 -1
  5. package/dist/IconButton.d.ts.map +1 -1
  6. package/dist/PreviewCard.d.ts +9 -2
  7. package/dist/PreviewCard.d.ts.map +1 -1
  8. package/dist/SectionHeader.d.ts +7 -1
  9. package/dist/SectionHeader.d.ts.map +1 -1
  10. package/dist/Select.d.ts +11 -3
  11. package/dist/Select.d.ts.map +1 -1
  12. package/dist/Tooltip.d.ts +13 -1
  13. package/dist/Tooltip.d.ts.map +1 -1
  14. package/dist/eslint/index.js +2 -1
  15. package/dist/eslint/index.js.map +2 -2
  16. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  17. package/dist/index.js +127 -108
  18. package/dist/index.js.map +2 -2
  19. package/package.json +1 -1
  20. package/registry.json +51 -8
  21. package/src/AppShell.mdx +10 -0
  22. package/src/AppShell.test.tsx +16 -0
  23. package/src/AppShell.tsx +4 -1
  24. package/src/AttachmentCard.mdx +9 -0
  25. package/src/AttachmentCard.test.tsx +10 -0
  26. package/src/AttachmentCard.tsx +8 -5
  27. package/src/Avatar.mdx +13 -3
  28. package/src/AvatarGroup.mdx +4 -0
  29. package/src/Banner.mdx +13 -3
  30. package/src/Breadcrumb.mdx +10 -0
  31. package/src/Button.mdx +14 -3
  32. package/src/Card.mdx +9 -0
  33. package/src/Checkbox.mdx +10 -0
  34. package/src/Chip.mdx +4 -0
  35. package/src/ChipInput.mdx +12 -0
  36. package/src/CollapsibleSection.mdx +9 -0
  37. package/src/CommandPalette.mdx +14 -2
  38. package/src/ConfirmDialog.mdx +11 -0
  39. package/src/ContainerHeader.mdx +4 -0
  40. package/src/DialogShell.mdx +14 -0
  41. package/src/Divider.mdx +9 -1
  42. package/src/EditableText.mdx +11 -0
  43. package/src/EmptyState.mdx +15 -4
  44. package/src/Field.mdx +10 -1
  45. package/src/FieldLine.mdx +9 -1
  46. package/src/FilePicker.mdx +8 -0
  47. package/src/Form.mdx +10 -0
  48. package/src/Form.test.tsx +26 -0
  49. package/src/Form.tsx +7 -0
  50. package/src/IconButton.mdx +16 -2
  51. package/src/IconButton.stories.tsx +3 -0
  52. package/src/IconButton.test.tsx +25 -0
  53. package/src/IconButton.tsx +61 -46
  54. package/src/IdentityMenu.mdx +9 -0
  55. package/src/InlineChip.mdx +8 -0
  56. package/src/Kbd.mdx +4 -0
  57. package/src/Link.mdx +10 -0
  58. package/src/Menu.mdx +16 -1
  59. package/src/MenuItem.mdx +12 -2
  60. package/src/NavItem.mdx +8 -0
  61. package/src/Person.mdx +4 -0
  62. package/src/PersonTrigger.mdx +9 -0
  63. package/src/Popover.mdx +21 -0
  64. package/src/PreviewCard.mdx +19 -4
  65. package/src/PreviewCard.tsx +11 -4
  66. package/src/ProgressBar.mdx +8 -0
  67. package/src/Property.mdx +4 -0
  68. package/src/Rail.mdx +9 -1
  69. package/src/RailItem.mdx +8 -0
  70. package/src/Reaction.mdx +9 -0
  71. package/src/ReactionPicker.mdx +8 -0
  72. package/src/ScrollArea.mdx +13 -2
  73. package/src/SearchInput.mdx +9 -0
  74. package/src/SectionHeader.mdx +13 -0
  75. package/src/SectionHeader.stories.tsx +9 -0
  76. package/src/SectionHeader.test.tsx +9 -0
  77. package/src/SectionHeader.tsx +8 -2
  78. package/src/SectionLabel.mdx +4 -0
  79. package/src/Select.mdx +21 -2
  80. package/src/Select.stories.tsx +4 -1
  81. package/src/Select.test.tsx +17 -0
  82. package/src/Select.tsx +34 -13
  83. package/src/Sidebar.mdx +8 -0
  84. package/src/Skeleton.mdx +4 -0
  85. package/src/Tabs.mdx +12 -1
  86. package/src/TextInput.mdx +8 -0
  87. package/src/Textarea.mdx +8 -0
  88. package/src/Toast.mdx +12 -1
  89. package/src/Toolbar.mdx +11 -0
  90. package/src/Tooltip.mdx +26 -2
  91. package/src/Tooltip.stories.tsx +26 -0
  92. package/src/Tooltip.test.tsx +29 -0
  93. package/src/Tooltip.tsx +17 -4
  94. package/src/TopBar.mdx +4 -0
  95. package/src/eslint/no-rebuilt-behaviour.ts +1 -1
  96. package/src/eslint/no-restyled-part.ts +1 -0
  97. package/src/pages.test.ts +142 -0
  98. package/tailwind-preset.js +22 -1
@@ -31,6 +31,32 @@ export const ShortcutComparison: Story = {
31
31
  ),
32
32
  }
33
33
 
34
+ /** A label longer than 320px wraps onto more lines, rather than running off the screen. A short one stays one line at 30px. */
35
+ export const LongLabel: Story = {
36
+ parameters: { controls: { disable: true } },
37
+ render: () => (
38
+ <div className="flex flex-col items-start gap-2">
39
+ <Tooltip label="Comment" />
40
+ <Tooltip label="naddr1qvzqqqr4gupzq9h35qgq6n8ll0xyyv8gurjzjrx9sjwp4hry6ejnlks8cqcmzp6tqyfhwumn8ghj7mmxve3ksctfdch8qatz9uq3wamnwvaz7tmjv4kxz7fwwpexjmtpdshxuet59uq3qamnwvaz7tm99ehx2aqqz9mhxue69uhkummnw3ez6un9d3shjtnwda4k7tnr" />
41
+ <Tooltip label="Item one, with a title long enough that it no longer fits on one line of a tooltip" />
42
+ </div>
43
+ ),
44
+ }
45
+
46
+ /** `inline`: a tooltip on a word inside a sentence. The wrapper is a span, so the paragraph stays valid HTML. Hover the word. */
47
+ export const InsideText: Story = {
48
+ parameters: { controls: { disable: true } },
49
+ render: () => (
50
+ <p className="max-w-sm text-body-2 text-text-primary">
51
+ The plan is in{' '}
52
+ <WithTooltip inline label="Item one, with a title long enough that it no longer fits on one line of a tooltip">
53
+ <span className="font-semibold">Item one</span>
54
+ </WithTooltip>
55
+ , and the rest follows.
56
+ </p>
57
+ ),
58
+ }
59
+
34
60
  /** Hover the button — WithTooltip portals the tooltip above the trigger. */
35
61
  export const OnHoverTop: Story = {
36
62
  parameters: { controls: { disable: true } },
@@ -27,6 +27,14 @@ describe('Tooltip, the surface', () => {
27
27
  expect(tip.textContent).toBe('BoldCmd+B')
28
28
  })
29
29
 
30
+ it('wraps a long label at 320px instead of running it off the screen, and stays 30px on one line', () => {
31
+ render(<Tooltip label="A label long enough to wrap onto a second line of the tooltip" />)
32
+ const pill = screen.getByRole('tooltip')
33
+ expect(pill.className).toContain('max-w-80')
34
+ expect(pill.className).toContain('min-h-[30px]')
35
+ expect(pill.querySelector('span')?.className).not.toContain('whitespace-nowrap')
36
+ })
37
+
30
38
  it('takes a caller class without losing its own', () => {
31
39
  render(<Tooltip label="Bold" className="w-40" />)
32
40
  const tip = screen.getByRole('tooltip')
@@ -49,6 +57,27 @@ describe('WithTooltip', () => {
49
57
  expect(wrapper.firstElementChild?.tagName).toBe('BUTTON')
50
58
  })
51
59
 
60
+ it('inline: a span wrapper, so it can sit inside a paragraph without React warning', () => {
61
+ const errors: unknown[] = []
62
+ const original = console.error
63
+ console.error = (...args: unknown[]) => { errors.push(args) }
64
+ try {
65
+ render(
66
+ <p>
67
+ The plan is in{' '}
68
+ <WithTooltip inline label="Item one">
69
+ <span>Item one</span>
70
+ </WithTooltip>
71
+ .
72
+ </p>,
73
+ )
74
+ } finally {
75
+ console.error = original
76
+ }
77
+ expect(screen.getByText('Item one').parentElement?.tagName).toBe('SPAN')
78
+ expect(errors).toEqual([])
79
+ })
80
+
52
81
  it('takes wrapperClassName, which is what keeps a truncating label truncating', () => {
53
82
  const { container } = render(
54
83
  <WithTooltip label="A long name" wrapperClassName="min-w-0 shrink">
package/src/Tooltip.tsx CHANGED
@@ -64,10 +64,16 @@ export interface TooltipProps extends Omit<ComponentPropsWithRef<'div'>, 'childr
64
64
  shortcut?: string
65
65
  }
66
66
 
67
+ /**
68
+ * One line at 30px, as it has always been; a label longer than 320px wraps
69
+ * onto more lines rather than running off the screen (Katerina, 19 September:
70
+ * a reference's full address drew a pill 950px wide). `py-1` only shows once
71
+ * it wraps: a single line is centred in the 30px.
72
+ */
67
73
  export function Tooltip({ label, shortcut, className, ...props }: TooltipProps) {
68
74
  return (
69
- <div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg h-[30px] flex items-center justify-center gap-1.5 px-2 shadow-lg', className)} {...props}>
70
- <span className="text-caption text-text-primary whitespace-nowrap">{label}</span>
75
+ <div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg min-h-[30px] max-w-80 flex items-center justify-center gap-1.5 px-2 py-1 shadow-lg', className)} {...props}>
76
+ <span className="min-w-0 text-caption text-text-primary break-words">{label}</span>
71
77
  {shortcut && <Kbd>{shortcut}</Kbd>}
72
78
  </div>
73
79
  )
@@ -80,6 +86,12 @@ export interface WithTooltipProps {
80
86
  placement?: 'top' | 'bottom'
81
87
  /** Extra classes on the wrapper — e.g. `min-w-0 shrink` so a truncating label keeps truncating inside it. */
82
88
  wrapperClassName?: string
89
+ /**
90
+ * For a trigger inside a line of text — a reference, a name in a sentence:
91
+ * the wrapper is a `<span>`, because a `<div>` is not allowed inside a
92
+ * paragraph (UIG-14, Katerina, 19 September).
93
+ */
94
+ inline?: boolean
83
95
  children: ReactNode
84
96
  }
85
97
 
@@ -150,7 +162,8 @@ function TooltipSurface({ label, shortcut, placement }: { label: string; shortcu
150
162
  * an icon. It wraps what it is given in an element that carries the handlers.
151
163
  * A control that can be the trigger does it itself: `IconButton` takes a
152
164
  * `tooltip` prop, and `Button` shows a `disabledReason` the same way. */
153
- export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, children }: WithTooltipProps) {
165
+ export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, inline = false, children }: WithTooltipProps) {
166
+ const Wrapper = inline ? 'span' : 'div'
154
167
  return (
155
168
  <BaseTooltip.Root disableHoverablePopup>
156
169
  {/* The wrapper stays the trigger: `WithTooltip` wraps whatever it is
@@ -158,7 +171,7 @@ export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassNa
158
171
  can carry the handlers for all of them. A control that IS the trigger
159
172
  (Button, IconButton) composes the part onto itself instead, which is
160
173
  what lets a `Dialog.Close` or a `Menu.Trigger` be one of those. */}
161
- <BaseTooltip.Trigger delay={OPEN_DELAY} render={<div className={cn('inline-flex shrink-0', wrapperClassName)} />}>
174
+ <BaseTooltip.Trigger delay={OPEN_DELAY} render={<Wrapper className={cn('inline-flex shrink-0', wrapperClassName)} />}>
162
175
  {children}
163
176
  </BaseTooltip.Trigger>
164
177
  <TooltipSurface label={label} shortcut={shortcut} placement={placement} />
package/src/TopBar.mdx CHANGED
@@ -57,6 +57,10 @@ import { TopBar, IdentityMenu, SearchInput } from '@estiva-app/ui'
57
57
  - The frame is desktop-only for now — there is no narrow-screen
58
58
  behaviour; the menu button collapses panels, it does not open a drawer.
59
59
 
60
+ ## What it owns
61
+
62
+ Nothing. It only draws. Clicks and keys are yours.
63
+
60
64
  ## Props
61
65
 
62
66
  <Controls of={TopBarStories.Solid} />
@@ -202,7 +202,7 @@ export const OWNED_BEHAVIOURS: OwnedBehaviour[] = [
202
202
  },
203
203
  {
204
204
  id: 'page-keys',
205
- behaviour: 'Closes on Escape, and takes its keys, by itself',
205
+ behaviour: 'Takes its keys by itself',
206
206
  baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox', 'Tooltip', 'PreviewCard', 'Toast'],
207
207
  owners: ['DialogShell', 'Popover', 'Menu', 'Select', 'Tabs', 'Toolbar'],
208
208
  reads: 'a keydown, keyup or keypress listener on window or document',
@@ -118,6 +118,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
118
118
  RailItem: ['active'],
119
119
  Reaction: ['pressed'],
120
120
  ReactionPicker: ['surface'],
121
+ SectionHeader: ['hover'],
121
122
  SectionLabel: ['tone'],
122
123
  Select: ['size'],
123
124
  Tabs: ['size'],
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The contract every component page keeps (UIG-14, with UIG-15 and UIG-16 folded in).
3
+ *
4
+ * A page opens with a line saying what the part is, then has `When`, `When not`,
5
+ * `How` (with code) and `What it owns`, in that order, before `Keys` and `Props`.
6
+ *
7
+ * `What it owns` reads the checker's own list back. A row ticked ✓ in its third
8
+ * column is a behaviour from `OWNED_BEHAVIOURS` that one of the page's parts owns,
9
+ * worded as `ROW` below; every behaviour the page's parts own has one; and a row
10
+ * worded as a behaviour the parts do not own fails. So the page and the checker
11
+ * cannot say different things. A part that owns nothing says so in one line.
12
+ *
13
+ * A `When not` line names only parts that exist: a bold name is a value export of
14
+ * `index.ts`, or a page.
15
+ */
16
+ import { readdirSync, readFileSync } from 'node:fs'
17
+ import { describe, expect, it } from 'vitest'
18
+ import { OWNED_BEHAVIOURS } from './eslint/no-rebuilt-behaviour'
19
+
20
+ /** How a page words each behaviour. `base-ui` is how a part is built, not what it does for a caller. */
21
+ const ROW: Record<string, RegExp> = {
22
+ portal: /^Floats on top of the page\b/,
23
+ 'press-outside': /^Closes on a press outside\b/,
24
+ 'page-keys': /^Takes its keys by itself\b/,
25
+ focus: /^Holds focus inside while open\b/,
26
+ 'scroll-lock': /^Stops the page behind it scrolling\b/,
27
+ follow: /^Stays attached to its anchor\b/,
28
+ walking: /^Moves through its items with the arrow keys\b/,
29
+ role: /^Says what it is to assistive technology\b/,
30
+ 'tab-stop': /^Is reachable with Tab\b/,
31
+ scroll: /^Scrolls with our scrollbar\b/,
32
+ }
33
+ const NOT_A_ROW = ['base-ui']
34
+
35
+ /** A page whose part another ticket is changing, while it is; empty when none is. */
36
+ const WAITING: string[] = []
37
+
38
+ const NOTHING = 'Nothing. It only draws. Clicks and keys are yours.'
39
+
40
+ const dir = new URL('.', import.meta.url)
41
+ const read = (file: string) => readFileSync(new URL(file, dir), 'utf8').replace(/\r\n/g, '\n')
42
+ const pages = readdirSync(dir)
43
+ .filter((f) => f.endsWith('.mdx'))
44
+ .map((f) => f.slice(0, -4))
45
+
46
+ const index = read('index.ts')
47
+ /** Value exports of index.ts, by the module they come from. */
48
+ const byModule = new Map<string, string[]>()
49
+ for (const m of index.matchAll(/export\s*\{([^}]*)\}\s*from\s*'\.\/([\w/]+)'/g)) {
50
+ const names = m[1]
51
+ .split(',')
52
+ .map((s) => s.trim())
53
+ .filter((s) => s && !s.startsWith('type '))
54
+ .map((s) => s.split(/\s+as\s+/).pop()!)
55
+ byModule.set(m[2], [...(byModule.get(m[2]) ?? []), ...names])
56
+ }
57
+ const exported = new Set([...byModule.values()].flat())
58
+
59
+ /** The parts a page documents: its own name, and its module's exports that have no page of their own. */
60
+ const partsOf = (page: string) => [page, ...(byModule.get(page) ?? []).filter((n) => n !== page && !pages.includes(n))]
61
+
62
+ const section = (t: string, name: string) => {
63
+ const head = `\n## ${name}\n`
64
+ const at = t.indexOf(head)
65
+ if (at < 0) return null
66
+ const rest = t.slice(at + head.length)
67
+ const end = rest.search(/^## /m)
68
+ return end < 0 ? rest : rest.slice(0, end)
69
+ }
70
+
71
+ const owns = (t: string) =>
72
+ (section(t, 'What it owns') ?? '')
73
+ .split('\n')
74
+ .filter((l) => l.startsWith('| ') && !l.startsWith('| It does |'))
75
+ .map((l) => l.split('|').map((c) => c.trim()))
76
+ .map(([, does, never, ticked]) => ({ does, never, ticked: ticked === '✓' }))
77
+
78
+ describe('the page contract', () => {
79
+ it('words every behaviour the checker knows', () => {
80
+ const ids = OWNED_BEHAVIOURS.map((b) => b.id).filter((id) => !NOT_A_ROW.includes(id))
81
+ expect(Object.keys(ROW).sort()).toEqual(ids.sort())
82
+ })
83
+
84
+ it('finds the pages', () => {
85
+ expect(pages.length).toBeGreaterThanOrEqual(54)
86
+ })
87
+
88
+ for (const page of pages) {
89
+ describe(page, () => {
90
+ const t = read(`${page}.mdx`)
91
+ const headings = [...t.matchAll(/^## (.+)$/gm)].map((m) => m[1])
92
+
93
+ it('opens with a line saying what it is', () => {
94
+ const opening = t.split(/^# .*$/m)[1]?.split(/\n\s*\n/).map((s) => s.trim()).find((s) => s && !s.startsWith('<'))
95
+ expect(opening, 'no opening line under the title').toBeTruthy()
96
+ })
97
+
98
+ it('has each section once', () => {
99
+ expect(headings.filter((h, i) => headings.indexOf(h) !== i)).toEqual([])
100
+ })
101
+
102
+ it('has its sections in order', () => {
103
+ const want = WAITING.includes(page) ? ['When', 'When not', 'How'] : ['When', 'When not', 'How', 'What it owns']
104
+ const at = want.map((h) => headings.indexOf(h))
105
+ expect(at.filter((i) => i < 0).length, `missing: ${want.filter((_, i) => at[i] < 0).join(', ')}`).toBe(0)
106
+ expect([...at].sort((a, b) => a - b)).toEqual(at)
107
+ for (const after of ['Keys', 'Props']) {
108
+ const i = headings.indexOf(after)
109
+ if (i >= 0) expect(at[at.length - 1], `${want[want.length - 1]} comes before ${after}`).toBeLessThan(i)
110
+ }
111
+ })
112
+
113
+ it('shows code under How', () => {
114
+ expect(section(t, 'How')).toMatch(/```tsx?\n/)
115
+ })
116
+
117
+ it('names only parts that exist under When not', () => {
118
+ const names = [...(section(t, 'When not') ?? '').matchAll(/\*\*`?([A-Z][A-Za-z0-9]*)`?\*\*/g)].map((m) => m[1])
119
+ expect(names.filter((n) => !exported.has(n) && !pages.includes(n))).toEqual([])
120
+ })
121
+
122
+ if (WAITING.includes(page)) return
123
+
124
+ it('says what it owns, as the checker does', () => {
125
+ const parts = partsOf(page)
126
+ const owned = OWNED_BEHAVIOURS.filter((b) => !NOT_A_ROW.includes(b.id) && b.owners.some((o) => parts.includes(o))).map((b) => b.id)
127
+ const rows = owns(t)
128
+ const body = (section(t, 'What it owns') ?? '').trim()
129
+ if (!rows.length) {
130
+ expect(owned, `${page} owns ${owned.join(', ')}; the page says nothing`).toEqual([])
131
+ expect(body).toBe(NOTHING)
132
+ return
133
+ }
134
+ const said = rows.flatMap((r) => Object.entries(ROW).filter(([, re]) => re.test(r.does)).map(([id]) => ({ id, ticked: r.ticked, does: r.does })))
135
+ expect(said.filter((s) => !s.ticked).map((s) => s.does), 'a checker behaviour, not ticked').toEqual([])
136
+ expect(rows.filter((r) => r.ticked && !Object.values(ROW).some((re) => re.test(r.does))).map((r) => r.does), 'ticked, but not worded as a checker behaviour').toEqual([])
137
+ expect(said.map((s) => s.id).sort()).toEqual([...owned].sort())
138
+ expect(rows.every((r) => r.does && r.never), 'every row says what it does and what you never write').toBe(true)
139
+ })
140
+ })
141
+ }
142
+ })
@@ -67,9 +67,30 @@ export default {
67
67
  // see tokens.css). Both exist so a treatment can be given to the two apps
68
68
  // without changing the plain light/dark themes the docs render in.
69
69
  plugins: [
70
- plugin(({ addVariant }) => {
70
+ plugin(({ addVariant, addComponents }) => {
71
71
  addVariant('signal', '.signal &')
72
72
  addVariant('ship', "[data-theme='ship'] &")
73
+ // The Signal canvas: the control-room dot grid on the app's dark ground,
74
+ // in the Signal theme only. AppShell's floating frame carries
75
+ // `signal-canvas`. Peek's, verbatim (UIG-14, Katerina, 19 September: the
76
+ // canvas is the theme's, so it moved with the frame). A component here,
77
+ // not base.css, so it reaches every app on the preset and the class lint
78
+ // knows the name. `isolation` and `z-index: -1` keep the grid above
79
+ // the ground and below every child: never over the card, a dialog, a menu.
80
+ addComponents({
81
+ '.signal .signal-canvas': { isolation: 'isolate' },
82
+ '.signal .signal-canvas::before': {
83
+ content: "''",
84
+ position: 'absolute',
85
+ inset: '0',
86
+ zIndex: '-1',
87
+ pointerEvents: 'none',
88
+ backgroundImage: 'radial-gradient(circle, rgba(255, 255, 255, 0.045) 1px, transparent 1px)',
89
+ backgroundSize: '26px 26px',
90
+ maskImage: 'radial-gradient(1100px 700px at 50% 45%, transparent 25%, #000 100%)',
91
+ WebkitMaskImage: 'radial-gradient(1100px 700px at 50% 45%, transparent 25%, #000 100%)',
92
+ },
93
+ })
73
94
  }),
74
95
  ],
75
96
  theme: {