@estiva-app/ui 0.57.4 → 0.59.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 (65) hide show
  1. package/dist/CommandPalette.d.ts.map +1 -1
  2. package/dist/ComposerTriggers.d.ts +13 -1
  3. package/dist/ComposerTriggers.d.ts.map +1 -1
  4. package/dist/ContainerHeader.d.ts.map +1 -1
  5. package/dist/DialogShell.d.ts.map +1 -1
  6. package/dist/Kbd.d.ts.map +1 -1
  7. package/dist/MembersDialog.d.ts +57 -0
  8. package/dist/MembersDialog.d.ts.map +1 -0
  9. package/dist/MembersPill.d.ts +22 -0
  10. package/dist/MembersPill.d.ts.map +1 -0
  11. package/dist/NavItem.d.ts.map +1 -1
  12. package/dist/SectionHeader.d.ts +7 -1
  13. package/dist/SectionHeader.d.ts.map +1 -1
  14. package/dist/ShowMore.d.ts +27 -0
  15. package/dist/ShowMore.d.ts.map +1 -0
  16. package/dist/Tabs.d.ts +2 -0
  17. package/dist/Tabs.d.ts.map +1 -1
  18. package/dist/editor.js +4 -4
  19. package/dist/editor.js.map +2 -2
  20. package/dist/eslint/index.js +1 -0
  21. package/dist/eslint/index.js.map +2 -2
  22. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1150 -994
  26. package/dist/index.js.map +4 -4
  27. package/dist/looks.d.ts +4 -0
  28. package/dist/looks.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/registry.json +212 -8
  31. package/src/CommandPalette.tsx +2 -1
  32. package/src/ComposerTriggers.mdx +7 -0
  33. package/src/ComposerTriggers.stories.tsx +43 -4
  34. package/src/ComposerTriggers.test.tsx +33 -0
  35. package/src/ComposerTriggers.tsx +17 -5
  36. package/src/ContainerHeader.mdx +1 -1
  37. package/src/ContainerHeader.tsx +3 -2
  38. package/src/DialogShell.tsx +2 -0
  39. package/src/Kbd.tsx +4 -2
  40. package/src/ListColumn.mdx +1 -1
  41. package/src/MembersDialog.mdx +69 -0
  42. package/src/MembersDialog.stories.tsx +68 -0
  43. package/src/MembersDialog.test.tsx +69 -0
  44. package/src/MembersDialog.tsx +184 -0
  45. package/src/MembersPill.mdx +45 -0
  46. package/src/MembersPill.stories.tsx +38 -0
  47. package/src/MembersPill.tsx +35 -0
  48. package/src/NavItem.tsx +3 -2
  49. package/src/SectionHeader.mdx +6 -4
  50. package/src/SectionHeader.stories.tsx +7 -2
  51. package/src/SectionHeader.test.tsx +6 -0
  52. package/src/SectionHeader.tsx +10 -2
  53. package/src/ShowMore.mdx +62 -0
  54. package/src/ShowMore.stories.tsx +39 -0
  55. package/src/ShowMore.test.tsx +44 -0
  56. package/src/ShowMore.tsx +63 -0
  57. package/src/Tabs.mdx +5 -0
  58. package/src/Tabs.stories.tsx +15 -0
  59. package/src/Tabs.test.tsx +8 -0
  60. package/src/Tabs.tsx +12 -0
  61. package/src/eslint/no-restyled-part.ts +1 -0
  62. package/src/index.ts +3 -0
  63. package/src/looks.ts +6 -0
  64. package/src/registry/registry.test.ts +4 -4
  65. package/stories/Choosing.mdx +4 -1
@@ -0,0 +1,44 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The page's claims: content taller than the limit is cut with "Show more",
4
+ * which opens it and says so; content that fits has no button at all.
5
+ */
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import { cleanup, render, screen } from '@testing-library/react'
8
+ import userEvent from '@testing-library/user-event'
9
+ import { ShowMore } from './ShowMore'
10
+
11
+ afterEach(() => {
12
+ cleanup()
13
+ vi.restoreAllMocks()
14
+ })
15
+
16
+ /** jsdom lays nothing out: every box says it is this tall. */
17
+ const contentHeight = (px: number) => vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(px)
18
+
19
+ describe('ShowMore', () => {
20
+ it('cuts content taller than the limit, and opens it', async () => {
21
+ contentHeight(300)
22
+ render(<ShowMore maxHeight={96}><p>Long</p></ShowMore>)
23
+ const button = screen.getByRole('button', { name: 'Show more' })
24
+ expect(button.getAttribute('aria-expanded')).toBe('false')
25
+ expect(screen.getByText('Long').parentElement!.style.maxHeight).toBe('96px')
26
+ await userEvent.click(button)
27
+ expect(screen.getByRole('button', { name: 'Show less' }).getAttribute('aria-expanded')).toBe('true')
28
+ expect(screen.getByText('Long').parentElement!.style.maxHeight).toBe('')
29
+ })
30
+
31
+ it('opens when focus arrives inside the cut content', async () => {
32
+ contentHeight(300)
33
+ render(<ShowMore maxHeight={96}><input aria-label="Field" /></ShowMore>)
34
+ await userEvent.click(screen.getByRole('textbox', { name: 'Field' }))
35
+ expect(screen.getByRole('button', { name: 'Show less' })).toBeTruthy()
36
+ })
37
+
38
+ it('draws content that fits whole, with no button', () => {
39
+ contentHeight(40)
40
+ render(<ShowMore maxHeight={96}><p>Short</p></ShowMore>)
41
+ expect(screen.queryByRole('button')).toBeNull()
42
+ expect(screen.getByText('Short').parentElement!.style.maxHeight).toBe('')
43
+ })
44
+ })
@@ -0,0 +1,63 @@
1
+ import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2
+ import { Button } from './Button'
3
+ import { cn } from './cn'
4
+
5
+ /**
6
+ * Long content cut at a height behind a fade, with "Show more" under it
7
+ * (Katerina, 7 October: a long description on a side panel pushed what was
8
+ * under it out of sight).
9
+ *
10
+ * Content no taller than `maxHeight` is drawn whole, with no fade and no
11
+ * button: the cut is for what would not fit, never a look. The height is
12
+ * measured, and measured again whenever the content changes size, so an
13
+ * edit that makes it long or short brings the button or takes it away.
14
+ *
15
+ * The fade ends in the surface the content sits on, which only the caller
16
+ * knows: `surface`, by name, so no app writes a colour into the part.
17
+ *
18
+ * Focus arriving inside the cut content opens it: a field or a link there,
19
+ * reached by Tab or a click, is never worked on out of sight.
20
+ */
21
+ export interface ShowMoreProps {
22
+ children: ReactNode
23
+ /** The height to cut at, in px. Default 96: about seven lines of caption text. */
24
+ maxHeight?: number
25
+ /** What the content sits on, for the fade to end in: a panel's `surface` (default), or an inset card's `inset`. */
26
+ surface?: 'surface' | 'inset'
27
+ className?: string
28
+ }
29
+
30
+ const FADE_CLASSES = { surface: 'from-bg-surface', inset: 'from-bg-inset' } as const
31
+
32
+ export function ShowMore({ children, maxHeight = 96, surface = 'surface', className }: ShowMoreProps) {
33
+ const box = useRef<HTMLDivElement>(null)
34
+ const [open, setOpen] = useState(false)
35
+ const [overflows, setOverflows] = useState(false)
36
+
37
+ useLayoutEffect(() => {
38
+ const el = box.current
39
+ if (!el) return
40
+ const measure = () => setOverflows(el.scrollHeight > maxHeight + 1)
41
+ measure()
42
+ if (typeof ResizeObserver === 'undefined') return
43
+ const observer = new ResizeObserver(measure)
44
+ observer.observe(el)
45
+ return () => observer.disconnect()
46
+ }, [maxHeight])
47
+
48
+ const cut = overflows && !open
49
+ return (
50
+ <div className={cn('flex flex-col items-start', className)}>
51
+ <div ref={box} className={cn('relative w-full', cut && 'overflow-hidden')} style={cut ? { maxHeight } : undefined} onFocus={cut ? () => setOpen(true) : undefined}>
52
+ {children}
53
+ {cut && <div aria-hidden className={cn('pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t to-transparent', FADE_CLASSES[surface])} />}
54
+ </div>
55
+ {overflows && (
56
+ // `-ml-1.5`: the small button's own 6px inset, so its words start where the content's do.
57
+ <Button variant="muted" size="small" className="-ml-1.5 mt-1" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
58
+ {open ? 'Show less' : 'Show more'}
59
+ </Button>
60
+ )}
61
+ </div>
62
+ )
63
+ }
package/src/Tabs.mdx CHANGED
@@ -17,8 +17,13 @@ is a neutral fill, not the accent — selection is a place, not a highlight.
17
17
  baseline — the one way a number sits beside a label. A count of `0` is
18
18
  still drawn ("Assigned to me · 0" is an answer); an absent count draws
19
19
  nothing.
20
+ - A tab with `unread` draws the **UnreadDot** after its label (and count),
21
+ without the dot's 24px slot so the row keeps one height, and says "new" in
22
+ its accessible name.
20
23
  - Two sizes: `default` (14px) and `small` (12px, the denser geometry).
21
24
 
25
+ <Canvas of={TabsStories.WithUnread} />
26
+
22
27
  <Canvas of={TabsStories.WithAZero} />
23
28
 
24
29
  ## When not
@@ -74,6 +74,21 @@ export const WithAnIcon: Story = {
74
74
  ),
75
75
  }
76
76
 
77
+ /** `unread`: something new behind a tab you are not on — the accent dot after the label. */
78
+ export const WithUnread: Story = {
79
+ args: { tabs: [], active: 'overview', onChange: () => {} },
80
+ render: () => (
81
+ <Demo
82
+ initial="overview"
83
+ tabs={[
84
+ { id: 'overview', label: 'Overview' },
85
+ { id: 'activity', label: 'Activity', unread: true },
86
+ { id: 'all', label: 'All', count: 12, unread: true },
87
+ ]}
88
+ />
89
+ ),
90
+ }
91
+
77
92
  /** `small` is the 12px geometry. */
78
93
  export const Small: Story = {
79
94
  args: { tabs: [], active: 'conversations', onChange: () => {} },
package/src/Tabs.test.tsx CHANGED
@@ -123,6 +123,14 @@ describe('Tabs', () => {
123
123
  expect(list.getAttribute('aria-labelledby')).toBeNull()
124
124
  })
125
125
 
126
+ it('an unread tab draws the dot and says "new" in its name; the others draw neither', () => {
127
+ const tabs: TabDef<Id>[] = [{ id: 'one', label: 'One' }, { id: 'two', label: 'Two', count: 3, unread: true }, { id: 'three', label: 'Three' }]
128
+ render(<Tabs tabs={tabs} active="one" onChange={() => {}} />)
129
+ expect(screen.getByRole('tab', { name: /^Two.*\bnew$/ }).querySelector('[data-unread]')).not.toBeNull()
130
+ expect(screen.getByRole('tab', { name: 'One' }).querySelector('[data-unread]')).toBeNull()
131
+ expect(screen.getByRole('tab', { name: 'Three' }).querySelector('[data-unread]')).toBeNull()
132
+ })
133
+
126
134
  it('puts className on the outer box, around the row', () => {
127
135
  const { container } = render(<Tabs tabs={THREE} active="one" onChange={() => {}} className="mt-4" />)
128
136
  const outer = container.firstElementChild as HTMLElement
package/src/Tabs.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { Tabs as BaseTabs } from '@base-ui/react/tabs'
3
3
  import { cn } from './cn'
4
+ import { UnreadDot } from './UnreadDot'
4
5
 
5
6
  /**
6
7
  * A row of tabs. Ship's Tabs (2026-09-01), which was Peek's TopicTabs with
@@ -26,6 +27,8 @@ export interface TabDef<T extends string> {
26
27
  count?: number
27
28
  /** 16px, stroke 1.5. */
28
29
  icon?: ReactNode
30
+ /** Something new behind this tab: the `UnreadDot` after the label, and "new" in the tab's accessible name. */
31
+ unread?: boolean
29
32
  }
30
33
 
31
34
  export interface TabsProps<T extends string> {
@@ -91,6 +94,15 @@ export function Tabs<T extends string>({ tabs, active, onChange, size = 'default
91
94
  <span className="ml-2.5 font-mono text-caption tabular-nums text-text-secondary">{tab.count}</span>
92
95
  ) : null}
93
96
  </span>
97
+ {/* The dot without its 24px slot: a tab is a line of text, and the
98
+ slot would make an unread tab taller than its neighbours. The
99
+ disc says nothing to a screen reader, so the name says "new". */}
100
+ {tab.unread ? (
101
+ <>
102
+ <UnreadDot className="h-auto w-auto" />
103
+ <span className="sr-only">new</span>
104
+ </>
105
+ ) : null}
94
106
  </BaseTabs.Tab>
95
107
  ))}
96
108
  </BaseTabs.List>
@@ -151,6 +151,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
151
151
  RichText: ['size'],
152
152
  SectionHeader: ['hover', 'look'],
153
153
  SectionLabel: ['tone', 'truncate'],
154
+ ShowMore: ['surface'],
154
155
  Select: ['size'],
155
156
  Tabs: ['size'],
156
157
  TextInput: ['size'],
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export { AppShell, type AppShellProps } from './AppShell'
13
13
  export { AttachmentCard, type AttachmentCardProps, type AttachmentCardState } from './AttachmentCard'
14
14
  export { Avatar, hueFor, initialsFor, type AvatarProps } from './Avatar'
15
15
  export { AvatarGroup, type AvatarGroupMember, type AvatarGroupProps } from './AvatarGroup'
16
+ export { MembersPill, type MembersPillProps } from './MembersPill'
17
+ export { MembersDialog, type MembersDialogCandidate, type MembersDialogMember, type MembersDialogProps, type MembersDialogSelfAction } from './MembersDialog'
16
18
  export { Banner, type BannerProps, type BannerTone } from './Banner'
17
19
  export { BlockHandle, blockAtY, type BlockHandleProps, type BlockHandleEditing, type HandleBlock } from './BlockHandle'
18
20
  export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button'
@@ -76,6 +78,7 @@ export { NavTree, NavTreeSection, type NavTreeGroup, type NavTreeNode, type NavT
76
78
  export { Popover, type PopoverProps } from './Popover'
77
79
  export { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuProps, type SuggestionMenuRow, type SuggestionMenuSection } from './SuggestionMenu'
78
80
  export { ScrollArea, type ScrollAreaProps } from './ScrollArea'
81
+ export { ShowMore, type ShowMoreProps } from './ShowMore'
79
82
  export { ReactionPicker, type ReactionOption, type ReactionPickerProps } from './ReactionPicker'
80
83
  export { Toolbar, ToolbarButton, ToolbarInput, ToolbarLink, ToolbarSeparator, type ToolbarProps, type ToolbarButtonProps, type ToolbarInputProps, type ToolbarLinkProps } from './Toolbar'
81
84
  export { PreviewCard, type PreviewCardProps } from './PreviewCard'
package/src/looks.ts CHANGED
@@ -37,9 +37,15 @@ export const FIELD_SHELL_CLASSES = 'bg-bg-field border border-border-field shado
37
37
  /** A panel that floats over the page: Menu's panel and Tooltip. */
38
38
  export const FLOATING_SURFACE_CLASSES = 'rounded-lg border border-border-default bg-bg-elevated shadow-lg'
39
39
 
40
+ /** A count beside a label, in the muted colour: NavItem's number and SectionHeader's `count`. */
41
+ export const COUNT_TEXT_CLASSES = 'font-mono text-caption tabular-nums text-text-muted'
42
+
40
43
  /** A chip's words: Chip's label and Reaction's count. */
41
44
  export const CHIP_TEXT_CLASSES = 'text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums'
42
45
 
46
+ /** Small secondary words that turn mono under signal: Kbd's key, CommandPalette's footer and MembersPill's count. */
47
+ export const MONO_CAPTION_CLASSES = 'text-caption text-text-secondary signal:font-mono signal:text-small'
48
+
43
49
  /** Words typed straight into an input with no box of its own: SearchInput and ChipInput. */
44
50
  export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:text-text-muted outline-none'
45
51
 
@@ -48,19 +48,19 @@ describe('the registry builds', () => {
48
48
  expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
49
49
  })
50
50
 
51
- it('counts 130 names over 69 files: 100 components, 29 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, and moveBlock and blockDropCursor, the drop in the gutter, on 5 October)', () => {
51
+ it('counts 133 names over 72 files: 103 components, 29 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, and ShowMore on 7 October)', () => {
52
52
  // The reconciliation GATES.md §24 explains. If this changes, that changes.
53
53
  // The ten `EDITOR_EXTENSIONS` count as components by their names; they are
54
54
  // editor extensions, documented on SelectionToolbar's, BlockNodes' and
55
55
  // ComposerTriggers' pages.
56
56
  const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
57
- expect(kinds).toEqual({ component: 100, helper: 29, hook: 1 })
58
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(69)
57
+ expect(kinds).toEqual({ component: 103, helper: 29, hook: 1 })
58
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(72)
59
59
  })
60
60
 
61
61
  it('gives every entry a purpose, from its own page or from the comment above it', () => {
62
62
  expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
63
- expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(65)
63
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(68)
64
64
  expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(65)
65
65
  })
66
66
 
@@ -51,6 +51,8 @@ fork freely, owe nothing back, mention it on the package's ticket.
51
51
  |---|---|
52
52
  | One face | **Avatar** |
53
53
  | Several faces, overlapping | **AvatarGroup** |
54
+ | Who is in it, at a glance, opening the list | **MembersPill** |
55
+ | Who is in it, with Join, Leave and adding people | **MembersDialog** |
54
56
  | Face and name in a row | **Person** |
55
57
  | The face that opens a menu | **PersonTrigger** |
56
58
  | Who you are, where you are, sign out | **IdentityMenu** — the account menu, ready-made |
@@ -83,10 +85,11 @@ fork freely, owe nothing back, mention it on the package's ticket.
83
85
  | Label–value rows (a details rail) | **Property** — `row` and `stacked` |
84
86
  | The bar across the top of a column: its title, and the buttons that act on the whole column | **ContainerHeader** — inside the package's columns; in an app, the header comes with **Panel** or **ListColumn** |
85
87
  | A box that can hold more than fits — a list that grows with its data counts, even when today's data fits | **ScrollArea** — its bar is drawn over the content and takes no width |
88
+ | Text that can run long above things the reader needs, cut behind a fade | **ShowMore** — measures; what fits has no button |
86
89
  | A status, a count or a category, in a small pill | **Chip** — six colour types; the colour is the message, the label makes it legible |
87
90
  | Something new on a row or a card, no count | **UnreadDot** — the 6px dot in a 24px slot; in a sidebar row, **NavItem** `unread` draws it |
88
91
  | One thing — a conversation, a project, an object — as a box you can pick | **Card** — the fill by what it sits on; `href` for a link, `hover="fill"` in a feed |
89
- | A section heading, chevron, hover actions | **SectionHeader** |
92
+ | A section heading, chevron, hover actions, a count beside the title | **SectionHeader** |
90
93
  | A group of rows that opens and closes, and stays how you left it | **CollapsibleSection** — `storageKey` remembers |
91
94
  | The uppercase micro-label | **SectionLabel** |
92
95
  | A hairline | **Divider** |