@estiva-app/ui 0.61.0 → 0.62.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.61.0",
3
+ "version": "0.62.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",
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.61.0",
7
+ "packageVersion": "0.62.1",
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": "The viewer's own row on top: \"Join\" when they are not in, \"Leave\" when they are."
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",
@@ -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` puts **Join** or **Leave** on top. Leave it out while membership is
47
- still being read: no row is better than a wrong one. The row waits while
48
- `onToggle`'s promise runs.
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: Leave and "Add members" on top. */
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 row on top rather than a wrong one. */
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
 
@@ -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
- * The viewer's own row on top: "Join" when they are not in, "Leave" when
38
- * they are. Absent while it is not known, or where they cannot change it —
39
- * no row is better than a wrong one.
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[]
@@ -57,7 +60,8 @@ export interface MembersDialogProps {
57
60
  onClose: () => void
58
61
  }
59
62
 
60
- const LEADING_CLASSES = 'size-8 rounded-md bg-accent-muted flex items-center justify-center shrink-0 text-accent-primary'
63
+ // The icon in the accent as text: Ship's button blue read at 2.7:1 on its grey square (Katerina, 8 October).
64
+ const LEADING_CLASSES = 'size-8 rounded-md bg-accent-muted flex items-center justify-center shrink-0 text-accent-text'
61
65
 
62
66
  /**
63
67
  * Who is in this, with joining, leaving and adding people — the list a
@@ -65,8 +69,10 @@ const LEADING_CLASSES = 'size-8 rounded-md bg-accent-muted flex items-center jus
65
69
  *
66
70
  * Moved from Peek's `MembersDialog` (2026-10-06; Katerina's design of 22 July,
67
71
  * with the row and inset rulings of 15 September). Two layers in one dialog:
68
- * the roster, with the viewer's Join or Leave and "Add members" on top, and the
69
- * add layer, whose back arrow returns to the roster. Adding returns to the
72
+ * the roster, with "Add members" (and Join, for a non-member) on top, and the
73
+ * add layer, whose back arrow returns to the roster. Leave is a small red
74
+ * button on the viewer's own row, which always comes first (Katerina, 8
75
+ * October: a Leave row on top looked like an invite, the same as Add members). Adding returns to the
70
76
  * roster so the new people are visible at once.
71
77
  */
72
78
  export function MembersDialog({ members, count, self, candidates = [], onAdd, note, initialView = 'list', onClose }: MembersDialogProps) {
@@ -92,6 +98,11 @@ export function MembersDialog({ members, count, self, candidates = [], onAdd, no
92
98
  setView('list')
93
99
  }
94
100
 
101
+ // 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.
102
+ const ownRow = self?.action === 'leave' ? members.find((m) => m.id === self.id) : undefined
103
+ const roster = ownRow ? [ownRow, ...members.filter((m) => m !== ownRow)] : members
104
+ const selfOnTop = self && !ownRow
105
+
95
106
  if (view === 'add' && onAdd) {
96
107
  return (
97
108
  <DialogShell
@@ -146,9 +157,9 @@ export function MembersDialog({ members, count, self, candidates = [], onAdd, no
146
157
  >
147
158
  {/* MenuItem outside a Menu is a plain button. Its fill sits 8px in from the dialog's sides,
148
159
  and its own 12px inset puts the square over the rows' faces, 20px in, at the same 48px. */}
149
- {(self || onAdd) && (
160
+ {(selfOnTop || onAdd) && (
150
161
  <div className="flex flex-col px-2">
151
- {self && (
162
+ {selfOnTop && (
152
163
  <MenuItem
153
164
  size="tall"
154
165
  className="py-2"
@@ -169,13 +180,18 @@ export function MembersDialog({ members, count, self, candidates = [], onAdd, no
169
180
  )}
170
181
  </div>
171
182
  )}
172
- {members.map((member) => (
183
+ {roster.map((member) => (
173
184
  <div key={member.id} className="flex h-12 items-center gap-3 px-5">
174
185
  <Avatar size={32} name={member.name} src={member.picture} />
175
186
  <div className="flex min-w-0 flex-1 flex-col justify-center gap-[2px]">
176
187
  <div className="truncate text-body-2 text-text-primary">{member.name}</div>
177
188
  {member.caption && <div className="truncate text-caption text-text-secondary">{member.caption}</div>}
178
189
  </div>
190
+ {member === ownRow && (
191
+ <Button variant="destructive" size="small" disabled={toggling} onClick={() => void toggle()}>
192
+ Leave
193
+ </Button>
194
+ )}
179
195
  </div>
180
196
  ))}
181
197
  {note && <div className="px-5 pb-2 pt-3 text-caption text-text-secondary">{note}</div>}