@estiva-app/ui 0.60.2 → 0.62.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.
- package/dist/MembersDialog.d.ts +10 -5
- package/dist/MembersDialog.d.ts.map +1 -1
- package/dist/ShowMore.d.ts +7 -1
- package/dist/ShowMore.d.ts.map +1 -1
- package/dist/index.js +31 -9
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/registry.json +8 -2
- package/src/MembersDialog.mdx +7 -4
- package/src/MembersDialog.stories.tsx +16 -5
- package/src/MembersDialog.test.tsx +16 -2
- package/src/MembersDialog.tsx +23 -8
- package/src/ShowMore.mdx +7 -0
- package/src/ShowMore.stories.tsx +14 -0
- package/src/ShowMore.test.tsx +16 -0
- package/src/ShowMore.tsx +37 -6
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"kind": "package",
|
|
5
5
|
"repo": "estiva-ui",
|
|
6
6
|
"package": "@estiva-app/ui",
|
|
7
|
-
"packageVersion": "0.
|
|
7
|
+
"packageVersion": "0.62.0",
|
|
8
8
|
"exports": 134,
|
|
9
9
|
"typeExports": 141,
|
|
10
10
|
"files": null
|
|
@@ -3480,7 +3480,7 @@
|
|
|
3480
3480
|
"name": "self",
|
|
3481
3481
|
"takes": "a handler",
|
|
3482
3482
|
"required": false,
|
|
3483
|
-
"note": "
|
|
3483
|
+
"note": "What the viewer can do: \"Leave\" on their own row when they are in, which is drawn first; a \"Join\" row on top when they are not."
|
|
3484
3484
|
},
|
|
3485
3485
|
{
|
|
3486
3486
|
"name": "candidates",
|
|
@@ -5872,6 +5872,12 @@
|
|
|
5872
5872
|
"required": false,
|
|
5873
5873
|
"note": "What the content sits on, for the fade to end in: a panel's `surface` (default), or an inset card's `inset`."
|
|
5874
5874
|
},
|
|
5875
|
+
{
|
|
5876
|
+
"name": "action",
|
|
5877
|
+
"takes": "anything",
|
|
5878
|
+
"required": false,
|
|
5879
|
+
"note": "A small muted `Button` with a leading icon, at the right of the toggle's row: the way out to where the content lives."
|
|
5880
|
+
},
|
|
5875
5881
|
{
|
|
5876
5882
|
"name": "className",
|
|
5877
5883
|
"takes": "text",
|
package/src/MembersDialog.mdx
CHANGED
|
@@ -25,6 +25,7 @@ Who is in this, with joining, leaving and adding people — the list a
|
|
|
25
25
|
```tsx
|
|
26
26
|
import { MembersDialog } from '@estiva-app/ui'
|
|
27
27
|
|
|
28
|
+
const myId = ''
|
|
28
29
|
const roster: { id: string; name: string; picture?: string; caption?: string }[] = []
|
|
29
30
|
const directory: { id: string; label: string; picture?: string }[] = []
|
|
30
31
|
const [open, setOpen] = useState(false)
|
|
@@ -32,7 +33,7 @@ const [open, setOpen] = useState(false)
|
|
|
32
33
|
{open && (
|
|
33
34
|
<MembersDialog
|
|
34
35
|
members={roster}
|
|
35
|
-
self={{ action: 'join', onToggle: () => {} }}
|
|
36
|
+
self={{ action: 'join', onToggle: () => {}, id: myId }}
|
|
36
37
|
candidates={directory}
|
|
37
38
|
onAdd={(chosen) => {}}
|
|
38
39
|
onClose={() => setOpen(false)}
|
|
@@ -43,9 +44,11 @@ const [open, setOpen] = useState(false)
|
|
|
43
44
|
- A row is `{ id, name, picture?, caption? }`. The caption is the second
|
|
44
45
|
line: a role, a relation such as "Assignee", or a state. Name the viewer's
|
|
45
46
|
own row "You".
|
|
46
|
-
- `self`
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
- `self` gives the viewer **Join** or **Leave**. `self.id` is their row in
|
|
48
|
+
`members`. A member's row comes first, with a small red **Leave** at its end.
|
|
49
|
+
A non-member has no row: **Join** is a row on top, like "Add members". Leave
|
|
50
|
+
`self` out while membership is still being read: no button is better than a
|
|
51
|
+
wrong one. The button waits while `onToggle`'s promise runs.
|
|
49
52
|
- `onAdd` shows **Add members**, which opens the add layer: an "Invite people"
|
|
50
53
|
**ChipInput** over `candidates`, leaving out anyone already a member by id.
|
|
51
54
|
Return `false` to stay on the layer; otherwise it returns to the roster.
|
|
@@ -16,7 +16,7 @@ const meta = {
|
|
|
16
16
|
{ id: 'me', name: 'You', caption: 'Product designer' },
|
|
17
17
|
{ id: 'ana', name: 'Ana Duarte', caption: 'Assignee' },
|
|
18
18
|
],
|
|
19
|
-
self: { action: 'leave', onToggle: () => {} },
|
|
19
|
+
self: { action: 'leave', onToggle: () => {}, id: 'me' },
|
|
20
20
|
candidates: people,
|
|
21
21
|
onAdd: () => {},
|
|
22
22
|
onClose: () => {},
|
|
@@ -26,23 +26,34 @@ const meta = {
|
|
|
26
26
|
export default meta
|
|
27
27
|
type Story = StoryObj<typeof meta>
|
|
28
28
|
|
|
29
|
-
/** A member:
|
|
29
|
+
/** A member: "Add members" on top, and Leave on your own row. */
|
|
30
30
|
export const Member: Story = {}
|
|
31
31
|
|
|
32
|
-
/** Not a member yet: Join on top, and nothing to add until you are in. */
|
|
32
|
+
/** Not a member yet: Join on top, no row of your own, and nothing to add until you are in. */
|
|
33
33
|
export const NotAMember: Story = {
|
|
34
34
|
args: {
|
|
35
35
|
members: [{ id: 'ana', name: 'Ana Duarte', caption: 'Assignee' }],
|
|
36
|
-
self: { action: 'join', onToggle: () => {} },
|
|
36
|
+
self: { action: 'join', onToggle: () => {}, id: 'me' },
|
|
37
37
|
onAdd: undefined,
|
|
38
38
|
},
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/** Membership still being read: no
|
|
41
|
+
/** Membership still being read: no Join or Leave rather than a wrong one. */
|
|
42
42
|
export const Unknown: Story = {
|
|
43
43
|
args: { self: undefined, onAdd: undefined },
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Your row comes first, whatever order the roster is given in. */
|
|
47
|
+
export const YouListedLast: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
members: [
|
|
50
|
+
{ id: 'ana', name: 'Ana Duarte', caption: 'Assignee' },
|
|
51
|
+
{ id: 'ravi', name: 'Ravi Mehta' },
|
|
52
|
+
{ id: 'me', name: 'You', caption: 'Author' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
/** Someone who left but still holds a role, and a line about what membership covers. */
|
|
47
58
|
export const WithNote: Story = {
|
|
48
59
|
args: {
|
|
@@ -31,10 +31,24 @@ describe('MembersDialog', () => {
|
|
|
31
31
|
it('says Join to a non-member and Leave to a member, and calls onToggle once', async () => {
|
|
32
32
|
const user = userEvent.setup()
|
|
33
33
|
const onToggle = vi.fn()
|
|
34
|
-
const { rerender } = render(<MembersDialog members={members} self={{ action: 'join', onToggle }} onClose={() => {}} />)
|
|
34
|
+
const { rerender } = render(<MembersDialog members={members.slice(1)} self={{ action: 'join', onToggle, id: 'me' }} onClose={() => {}} />)
|
|
35
35
|
await user.click(await screen.findByRole('button', { name: 'Join' }))
|
|
36
36
|
expect(onToggle).toHaveBeenCalledTimes(1)
|
|
37
|
-
rerender(<MembersDialog members={members} self={{ action: 'leave', onToggle }} onClose={() => {}} />)
|
|
37
|
+
rerender(<MembersDialog members={members} self={{ action: 'leave', onToggle, id: 'me' }} onClose={() => {}} />)
|
|
38
|
+
await user.click(await screen.findByRole('button', { name: 'Leave' }))
|
|
39
|
+
expect(onToggle).toHaveBeenCalledTimes(2)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("puts Leave on the viewer's own row, and that row first", async () => {
|
|
43
|
+
render(<MembersDialog members={[...members].reverse()} self={{ action: 'leave', onToggle: () => {}, id: 'me' }} onClose={() => {}} />)
|
|
44
|
+
const leave = await screen.findByRole('button', { name: 'Leave' })
|
|
45
|
+
const row = leave.parentElement!
|
|
46
|
+
expect(row.textContent).toContain('You')
|
|
47
|
+
expect(row.parentElement!.textContent!.indexOf('You')).toBeLessThan(row.parentElement!.textContent!.indexOf('Ana Duarte'))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('keeps Leave on top when the viewer has no row of their own', async () => {
|
|
51
|
+
render(<MembersDialog members={members.slice(1)} self={{ action: 'leave', onToggle: () => {}, id: 'me' }} onClose={() => {}} />)
|
|
38
52
|
expect(await screen.findByRole('button', { name: 'Leave' })).toBeTruthy()
|
|
39
53
|
})
|
|
40
54
|
|
package/src/MembersDialog.tsx
CHANGED
|
@@ -34,13 +34,16 @@ export interface MembersDialogProps {
|
|
|
34
34
|
/** The number in the title's chip. Defaults to how many rows there are. */
|
|
35
35
|
count?: number
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* no
|
|
37
|
+
* What the viewer can do: "Leave" on their own row when they are in, which
|
|
38
|
+
* is drawn first; a "Join" row on top when they are not. Absent while it is
|
|
39
|
+
* not known, or where they cannot change it — no button is better than a
|
|
40
|
+
* wrong one.
|
|
40
41
|
*/
|
|
41
42
|
self?: {
|
|
42
43
|
action: MembersDialogSelfAction
|
|
43
44
|
onToggle: () => void | Promise<unknown>
|
|
45
|
+
/** The viewer's id: the row in `members` that carries Leave and comes first. */
|
|
46
|
+
id: string
|
|
44
47
|
}
|
|
45
48
|
/** Who "Add members" may offer. Members are left out by id. */
|
|
46
49
|
candidates?: MembersDialogCandidate[]
|
|
@@ -65,8 +68,10 @@ const LEADING_CLASSES = 'size-8 rounded-md bg-accent-muted flex items-center jus
|
|
|
65
68
|
*
|
|
66
69
|
* Moved from Peek's `MembersDialog` (2026-10-06; Katerina's design of 22 July,
|
|
67
70
|
* with the row and inset rulings of 15 September). Two layers in one dialog:
|
|
68
|
-
* the roster, with
|
|
69
|
-
* add layer, whose back arrow returns to the roster.
|
|
71
|
+
* the roster, with "Add members" (and Join, for a non-member) on top, and the
|
|
72
|
+
* add layer, whose back arrow returns to the roster. Leave is a small red
|
|
73
|
+
* button on the viewer's own row, which always comes first (Katerina, 8
|
|
74
|
+
* October: a Leave row on top looked like an invite, the same as Add members). Adding returns to the
|
|
70
75
|
* roster so the new people are visible at once.
|
|
71
76
|
*/
|
|
72
77
|
export function MembersDialog({ members, count, self, candidates = [], onAdd, note, initialView = 'list', onClose }: MembersDialogProps) {
|
|
@@ -92,6 +97,11 @@ export function MembersDialog({ members, count, self, candidates = [], onAdd, no
|
|
|
92
97
|
setView('list')
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
// Leave sits on the viewer's own row, drawn first. A viewer with no row of their own gets Leave on top, as Join is.
|
|
101
|
+
const ownRow = self?.action === 'leave' ? members.find((m) => m.id === self.id) : undefined
|
|
102
|
+
const roster = ownRow ? [ownRow, ...members.filter((m) => m !== ownRow)] : members
|
|
103
|
+
const selfOnTop = self && !ownRow
|
|
104
|
+
|
|
95
105
|
if (view === 'add' && onAdd) {
|
|
96
106
|
return (
|
|
97
107
|
<DialogShell
|
|
@@ -146,9 +156,9 @@ export function MembersDialog({ members, count, self, candidates = [], onAdd, no
|
|
|
146
156
|
>
|
|
147
157
|
{/* MenuItem outside a Menu is a plain button. Its fill sits 8px in from the dialog's sides,
|
|
148
158
|
and its own 12px inset puts the square over the rows' faces, 20px in, at the same 48px. */}
|
|
149
|
-
{(
|
|
159
|
+
{(selfOnTop || onAdd) && (
|
|
150
160
|
<div className="flex flex-col px-2">
|
|
151
|
-
{
|
|
161
|
+
{selfOnTop && (
|
|
152
162
|
<MenuItem
|
|
153
163
|
size="tall"
|
|
154
164
|
className="py-2"
|
|
@@ -169,13 +179,18 @@ export function MembersDialog({ members, count, self, candidates = [], onAdd, no
|
|
|
169
179
|
)}
|
|
170
180
|
</div>
|
|
171
181
|
)}
|
|
172
|
-
{
|
|
182
|
+
{roster.map((member) => (
|
|
173
183
|
<div key={member.id} className="flex h-12 items-center gap-3 px-5">
|
|
174
184
|
<Avatar size={32} name={member.name} src={member.picture} />
|
|
175
185
|
<div className="flex min-w-0 flex-1 flex-col justify-center gap-[2px]">
|
|
176
186
|
<div className="truncate text-body-2 text-text-primary">{member.name}</div>
|
|
177
187
|
{member.caption && <div className="truncate text-caption text-text-secondary">{member.caption}</div>}
|
|
178
188
|
</div>
|
|
189
|
+
{member === ownRow && (
|
|
190
|
+
<Button variant="destructive" size="small" disabled={toggling} onClick={() => void toggle()}>
|
|
191
|
+
Leave
|
|
192
|
+
</Button>
|
|
193
|
+
)}
|
|
179
194
|
</div>
|
|
180
195
|
))}
|
|
181
196
|
{note && <div className="px-5 pb-2 pt-3 text-caption text-text-secondary">{note}</div>}
|
package/src/ShowMore.mdx
CHANGED
|
@@ -42,6 +42,13 @@ import { ShowMore } from '@estiva-app/ui'
|
|
|
42
42
|
(a panel, the default) or `inset` (inside an inset card). A fade into the
|
|
43
43
|
wrong colour shows as a band.
|
|
44
44
|
- `maxHeight` is in px, 96 by default.
|
|
45
|
+
- The toggle is a small outlined `Button` with a chevron: down on "Show more",
|
|
46
|
+
up on "Show less". Its edge starts where the content does.
|
|
47
|
+
- `action` is one more small muted `Button`, with a leading icon, on the toggle's row, at its
|
|
48
|
+
right: the way out to where the content lives ("Open in Estiva Ship"). The
|
|
49
|
+
row is drawn for it even when the content fits and there is no toggle.
|
|
50
|
+
|
|
51
|
+
<Canvas of={ShowMoreStories.WithAnAction} />
|
|
45
52
|
|
|
46
53
|
<Canvas of={ShowMoreStories.InACard} />
|
|
47
54
|
|
package/src/ShowMore.stories.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconExternalLink } from '@tabler/icons-react'
|
|
3
|
+
import { Button } from './Button'
|
|
2
4
|
import { Card } from './Card'
|
|
3
5
|
import { ShowMore } from './ShowMore'
|
|
4
6
|
|
|
@@ -37,3 +39,15 @@ export const InACard: Story = {
|
|
|
37
39
|
</Card>
|
|
38
40
|
),
|
|
39
41
|
}
|
|
42
|
+
|
|
43
|
+
/** `action`: one more small button, level with Show more at the right of its row. */
|
|
44
|
+
export const WithAnAction: Story = {
|
|
45
|
+
args: { surface: 'inset', action: <Button size="small" leadingIcon={<IconExternalLink stroke={1.5} className="size-3.5" />}>Open in Estiva Ship</Button> },
|
|
46
|
+
render: InACard.render,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Short content with an action: no toggle, and the action keeps its place at the right. */
|
|
50
|
+
export const ShortWithAnAction: Story = {
|
|
51
|
+
args: { ...WithAnAction.args, children: <p className="text-caption text-text-secondary">One line of description.</p> },
|
|
52
|
+
render: InACard.render,
|
|
53
|
+
}
|
package/src/ShowMore.test.tsx
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
7
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
8
8
|
import userEvent from '@testing-library/user-event'
|
|
9
|
+
import { Button } from './Button'
|
|
9
10
|
import { ShowMore } from './ShowMore'
|
|
10
11
|
|
|
11
12
|
afterEach(() => {
|
|
@@ -41,4 +42,19 @@ describe('ShowMore', () => {
|
|
|
41
42
|
expect(screen.queryByRole('button')).toBeNull()
|
|
42
43
|
expect(screen.getByText('Short').parentElement!.style.maxHeight).toBe('')
|
|
43
44
|
})
|
|
45
|
+
|
|
46
|
+
it('puts an action level with the toggle, at the right of its row', () => {
|
|
47
|
+
contentHeight(300)
|
|
48
|
+
render(<ShowMore maxHeight={96} action={<Button>Open</Button>}><p>Long</p></ShowMore>)
|
|
49
|
+
const row = screen.getByRole('button', { name: 'Show more' }).parentElement!
|
|
50
|
+
expect(row.contains(screen.getByRole('button', { name: 'Open' }))).toBe(true)
|
|
51
|
+
expect(row.className).toMatch(/justify-between/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('keeps the action when the content fits, with no toggle', () => {
|
|
55
|
+
contentHeight(40)
|
|
56
|
+
render(<ShowMore maxHeight={96} action={<Button>Open</Button>}><p>Short</p></ShowMore>)
|
|
57
|
+
expect(screen.queryByRole('button', { name: /Show/ })).toBeNull()
|
|
58
|
+
expect(screen.getByRole('button', { name: 'Open' })).toBeTruthy()
|
|
59
|
+
})
|
|
44
60
|
})
|
package/src/ShowMore.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { IconChevronDown, IconChevronUp } from '@tabler/icons-react'
|
|
2
3
|
import { Button } from './Button'
|
|
3
4
|
import { cn } from './cn'
|
|
4
5
|
|
|
@@ -17,6 +18,10 @@ import { cn } from './cn'
|
|
|
17
18
|
*
|
|
18
19
|
* Focus arriving inside the cut content opens it: a field or a link there,
|
|
19
20
|
* reached by Tab or a click, is never worked on out of sight.
|
|
21
|
+
*
|
|
22
|
+
* `action` is one more small button on the toggle's row, at its right
|
|
23
|
+
* (Katerina, 8 October: Peek's block card puts "Open in Estiva Ship" level with
|
|
24
|
+
* Show more). The row is drawn for it whether or not the content is cut.
|
|
20
25
|
*/
|
|
21
26
|
export interface ShowMoreProps {
|
|
22
27
|
children: ReactNode
|
|
@@ -24,12 +29,14 @@ export interface ShowMoreProps {
|
|
|
24
29
|
maxHeight?: number
|
|
25
30
|
/** What the content sits on, for the fade to end in: a panel's `surface` (default), or an inset card's `inset`. */
|
|
26
31
|
surface?: 'surface' | 'inset'
|
|
32
|
+
/** A small muted `Button` with a leading icon, at the right of the toggle's row: the way out to where the content lives. Its words end where the content does. */
|
|
33
|
+
action?: ReactNode
|
|
27
34
|
className?: string
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
const FADE_CLASSES = { surface: 'from-bg-surface', inset: 'from-bg-inset' } as const
|
|
31
38
|
|
|
32
|
-
export function ShowMore({ children, maxHeight = 96, surface = 'surface', className }: ShowMoreProps) {
|
|
39
|
+
export function ShowMore({ children, maxHeight = 96, surface = 'surface', action, className }: ShowMoreProps) {
|
|
33
40
|
const box = useRef<HTMLDivElement>(null)
|
|
34
41
|
const [open, setOpen] = useState(false)
|
|
35
42
|
const [overflows, setOverflows] = useState(false)
|
|
@@ -52,12 +59,36 @@ export function ShowMore({ children, maxHeight = 96, surface = 'surface', classN
|
|
|
52
59
|
{children}
|
|
53
60
|
{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
61
|
</div>
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
{
|
|
59
|
-
|
|
62
|
+
{action ? (
|
|
63
|
+
<div className={cn('mt-1 flex w-full items-center gap-2', overflows ? 'justify-between' : 'justify-end')}>
|
|
64
|
+
{overflows && <Toggle open={open} onToggle={() => setOpen((o) => !o)} />}
|
|
65
|
+
{/* `-mr-2`: a small button's 8px right inset beside a leading icon, so its words end where the content's do. */}
|
|
66
|
+
<div className="-mr-2 flex shrink-0">{action}</div>
|
|
67
|
+
</div>
|
|
68
|
+
) : (
|
|
69
|
+
overflows && <Toggle open={open} onToggle={() => setOpen((o) => !o)} className="mt-1" />
|
|
60
70
|
)}
|
|
61
71
|
</div>
|
|
62
72
|
)
|
|
63
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Outlined, with a chevron that says which way it goes (Katerina, 8 October):
|
|
77
|
+
* the one control of the part, so it has an edge. Its edge starts where the
|
|
78
|
+
* content does.
|
|
79
|
+
*/
|
|
80
|
+
function Toggle({ open, onToggle, className }: { open: boolean; onToggle: () => void; className?: string }) {
|
|
81
|
+
const Icon = open ? IconChevronUp : IconChevronDown
|
|
82
|
+
return (
|
|
83
|
+
<Button
|
|
84
|
+
variant="outlined"
|
|
85
|
+
size="small"
|
|
86
|
+
leadingIcon={<Icon stroke={1.5} className="size-3.5" />}
|
|
87
|
+
className={className}
|
|
88
|
+
aria-expanded={open}
|
|
89
|
+
onClick={onToggle}
|
|
90
|
+
>
|
|
91
|
+
{open ? 'Show less' : 'Show more'}
|
|
92
|
+
</Button>
|
|
93
|
+
)
|
|
94
|
+
}
|