@sableui/react 0.1.1 → 0.3.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 (137) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +120 -0
  3. package/CONTRIBUTING.md +8 -0
  4. package/README.md +1 -0
  5. package/bin/sable-lint.mjs +0 -0
  6. package/bin/sable.mjs +68 -26
  7. package/dist/{_contract-CYScOdf5.d.ts → _contract-o3tUc8Ig.d.ts} +1 -1
  8. package/dist/blocks/index.d.ts +396 -0
  9. package/dist/blocks/index.js +1997 -0
  10. package/dist/chunk-6LCIBMMG.js +4908 -0
  11. package/dist/{chunk-J23ELQQZ.js → chunk-CKNM7KSB.js} +1664 -4270
  12. package/dist/chunk-K6LII3OE.js +5093 -0
  13. package/dist/{chunk-RWP4XBSK.js → chunk-KFZ43RKU.js} +1 -1
  14. package/dist/{chunk-PAOZBZME.js → chunk-NGBDP3BM.js} +1 -1
  15. package/dist/{chunk-5Z5KLOAU.js → chunk-TH563WLW.js} +14 -1
  16. package/dist/{chunk-JI7WJP2L.js → chunk-X276MF2W.js} +5 -0
  17. package/dist/foundations/index.js +1 -1
  18. package/dist/icon/index.js +2 -2
  19. package/dist/icon-sets/reicon.js +1 -1
  20. package/dist/index.d.ts +624 -133
  21. package/dist/index.js +767 -4964
  22. package/dist/ios/index.d.ts +14 -2
  23. package/dist/ios/index.js +10 -9
  24. package/dist/ios/preview.d.ts +11 -1
  25. package/dist/ios/preview.js +14 -9
  26. package/dist/ios/styles.css +26 -6
  27. package/dist/{meta-6dUzgC6M.d.ts → meta-CcE_SeFu.d.ts} +37 -1
  28. package/dist/patterns/index.d.ts +1 -1
  29. package/dist/patterns/index.js +1684 -471
  30. package/dist/presets/index.d.ts +1 -1
  31. package/dist/presets/index.js +1 -1
  32. package/dist/styles.css +238 -40
  33. package/dist/{theme-provider-BWcizhAt.d.ts → theme-provider-BE-KqUG-.d.ts} +1 -1
  34. package/dist/themes/atelier/atelier.css +3 -0
  35. package/dist/themes/atelier/index.d.ts +1 -1
  36. package/dist/themes/base/base.css +34 -4
  37. package/dist/themes/base/index.d.ts +1 -1
  38. package/dist/themes/ember/ember.css +3 -0
  39. package/dist/themes/ember/index.d.ts +1 -1
  40. package/dist/themes/index.d.ts +2 -2
  41. package/dist/themes/index.js +2 -2
  42. package/dist/themes/meridian/index.d.ts +1 -1
  43. package/dist/themes/meridian/meridian.css +10 -5
  44. package/dist/themes/poppy/index.d.ts +1 -1
  45. package/dist/themes/poppy/poppy.css +16 -0
  46. package/dist/themes/prism/index.d.ts +1 -1
  47. package/dist/themes/prism/prism.css +4 -1
  48. package/dist/themes/verdant/index.d.ts +1 -1
  49. package/dist/themes/verdant/verdant.css +7 -2
  50. package/llms/blocks/api-keys-block.md +79 -0
  51. package/llms/blocks/chat-thread-block.md +51 -0
  52. package/llms/blocks/command-palette-block.md +48 -0
  53. package/llms/blocks/cta-band-block.md +62 -0
  54. package/llms/blocks/danger-zone-block.md +69 -0
  55. package/llms/blocks/device-comparison-block.md +91 -0
  56. package/llms/blocks/event-card-block.md +84 -0
  57. package/llms/blocks/faq-block.md +72 -0
  58. package/llms/blocks/feature-grid-block.md +75 -0
  59. package/llms/blocks/feedback-form-block.md +66 -0
  60. package/llms/blocks/goal-progress-block.md +54 -0
  61. package/llms/blocks/hero-block.md +65 -0
  62. package/llms/blocks/invite-team-block.md +61 -0
  63. package/llms/blocks/invoice-history-block.md +84 -0
  64. package/llms/blocks/kpi-sparkline-block.md +57 -0
  65. package/llms/blocks/media-player-block.md +100 -0
  66. package/llms/blocks/newsletter-block.md +64 -0
  67. package/llms/blocks/not-found-block.md +56 -0
  68. package/llms/blocks/notification-list-block.md +63 -0
  69. package/llms/blocks/notification-settings-block.md +54 -0
  70. package/llms/blocks/order-tracking-block.md +62 -0
  71. package/llms/blocks/pricing-card-block.md +82 -0
  72. package/llms/blocks/pricing-table-block.md +129 -0
  73. package/llms/blocks/product-card-block.md +69 -0
  74. package/llms/blocks/profile-card-block.md +103 -0
  75. package/llms/blocks/profile-form-block.md +114 -0
  76. package/llms/blocks/revenue-breakdown-block.md +70 -0
  77. package/llms/blocks/sign-in-block.md +64 -0
  78. package/llms/blocks/sign-up-block.md +78 -0
  79. package/llms/blocks/site-footer-block.md +96 -0
  80. package/llms/blocks/stat-row-block.md +59 -0
  81. package/llms/blocks/steps-block.md +73 -0
  82. package/llms/blocks/task-list-block.md +81 -0
  83. package/llms/blocks/team-members-block.md +91 -0
  84. package/llms/blocks/testimonial-block.md +78 -0
  85. package/llms/blocks/traffic-sources-block.md +63 -0
  86. package/llms/blocks/traffic-trend-block.md +95 -0
  87. package/llms/blocks/upload-list-block.md +66 -0
  88. package/llms/blocks/verify-code-block.md +59 -0
  89. package/llms/blocks/wallet-block.md +84 -0
  90. package/llms/components/area-chart.md +112 -0
  91. package/llms/components/bar-chart.md +131 -0
  92. package/llms/components/bubble.md +5 -5
  93. package/llms/components/button.md +1 -1
  94. package/llms/components/card.md +2 -2
  95. package/llms/components/checkbox-card.md +1 -1
  96. package/llms/components/context-card.md +2 -2
  97. package/llms/components/context-menu.md +1 -1
  98. package/llms/components/date-tile.md +79 -0
  99. package/llms/components/dialog.md +1 -1
  100. package/llms/components/empty-state.md +4 -4
  101. package/llms/components/entity.md +5 -3
  102. package/llms/components/fieldset.md +1 -1
  103. package/llms/components/kbd.md +18 -6
  104. package/llms/components/line-chart.md +101 -0
  105. package/llms/components/menu.md +1 -1
  106. package/llms/components/{dots-menu.md → overflow-menu.md} +9 -9
  107. package/llms/components/pie-chart.md +103 -0
  108. package/llms/components/pill.md +1 -1
  109. package/llms/components/radar-chart.md +106 -0
  110. package/llms/components/radial-chart.md +102 -0
  111. package/llms/components/segmented-bar.md +102 -0
  112. package/llms/components/settings-menu.md +2 -2
  113. package/llms/components/side-nav.md +1 -1
  114. package/llms/components/skeleton.md +1 -1
  115. package/llms/components/sparkline.md +80 -0
  116. package/llms/components/table.md +1 -1
  117. package/llms/components/theme-selector.md +1 -1
  118. package/llms/foundations/shape.md +1 -1
  119. package/llms/patterns/auth-page.md +132 -42
  120. package/llms/patterns/brand-landing-page.md +271 -175
  121. package/llms/patterns/chat-panel.md +120 -39
  122. package/llms/patterns/dashboard-page.md +276 -65
  123. package/llms/patterns/feed-page.md +245 -64
  124. package/llms/patterns/landing-page.md +473 -75
  125. package/llms/patterns/list-detail-page.md +377 -68
  126. package/llms/patterns/settings-page.md +268 -67
  127. package/llms/reference/token-intent.md +4 -2
  128. package/llms/reference/utilities.md +20 -5
  129. package/llms/tasks/building-an-ios-prototype.md +4 -1
  130. package/llms/tasks/choosing-a-component.md +2 -2
  131. package/llms-full.txt +6428 -892
  132. package/llms.txt +67 -16
  133. package/package.json +5 -1
  134. package/registry.json +3217 -552
  135. package/targets/ios.json +88 -18
  136. package/llms/components/description.md +0 -96
  137. package/dist/{chunk-L6Y46C62.js → chunk-MXJX3NZI.js} +1 -1
@@ -1,97 +1,406 @@
1
1
  # ListDetailPage
2
2
 
3
- Two-column list-detail: a wide region split 8/4, the record list as Entity rows in the main column, and a sticky supporting rail (detail, filters, or summary) beside it.
3
+ Searchable list beside the selected record's detail: interactive Entity rows in a CardList (5 of 12 columns) and a sticky detail pane (7) with an editable field, a label/value list, record actions with a destructive confirmation, and recent activity. Below lg it becomes a drill-in.
4
4
 
5
- **Intent:** Reach for this when a route lists records AND keeps one selected/summarized in view (members + member detail, inbox + message, orders + order). For pure browsing with no rail, a single-column FeedPage or DashboardPage table is simpler.
5
+ **Intent:** Reach for this when a route lists records AND works on one at a time (members + member, inbox + message, orders + order). For pure browsing with no selection, a single-column FeedPage or a DashboardPage table is simpler.
6
6
 
7
7
  ## Notes
8
8
 
9
- - Region cap is `wide`; the split is Columns 12 → Col 8 + Col 4, collapsing to stacked full-width columns below lg via breakpoint maps.
10
- - List rows are Entity inside one bordered Card, in CardList: it insets each row to the card padding and divides them with hairlines — on a dark canvas rows need an anchoring surface, not a flat float.
11
- - Pending records live in the same list, muted (muted title + `Invited` badge), instead of a separate section.
12
- - The rail card holds label/value pairs as a Description list — muted terms, mono (tabular) values — plus one quiet action. Never hand-built `justify-between` rows.
13
- - The rail is sticky (top on the 4px scale) so the summary stays in view while the list scrolls.
9
+ - Region cap is `wide`; Columns 12 → Col 5 (list) + Col 7 (detail). Below lg the list and detail share one column as a drill-in: selecting a row shows the detail with an `All members` back button (lg:hidden), and the back button returns to the list. The panes swap with `max-lg:hidden`, so desktop always shows both.
10
+ - Rows are interactive Entity inside one Card + CardList. Selection is Entity `selected`; `sable-surface` on the list card makes its selected step 12% ink off the card, so a selected row stays distinct from its muted avatar.
11
+ - Search filters the list in place (name, email, role). No matches swap the CardList for an EmptyState with a Clear search action; the selected detail stays open.
12
+ - A pending invite lives in the same list: a mail glyph in the Avatar, an `Invited` Badge, and an invite-specific detail (role on joining, sent, expires, invited by; Resend and Revoke actions).
13
+ - The role Select edits the record in place; its Field description states what the role can do. The owner's role is read-only and the owner has no remove action.
14
+ - Read-only facts are a native `<dl className="sable-list-dl sable-list-dl-2">` inside an `@container`, so pairs go two-up only when the pane is wide enough.
15
+ - Remove / revoke run through DestructiveActionModal; on confirm the next row is selected. Page-level counts (members, pending, seats) live in the PageHeader description and update with the list.
16
+ - The detail pane is sticky (lg:top-6) so it stays in view while a long list scrolls.
14
17
 
15
18
  ## Accessibility
16
19
 
17
- - Entity rows given `interactive` + `onSelect` (to open a member's detail) render as real buttons; keyboard focus follows list order. Rows here are presentational.
18
- - The rail is an <aside> landmark; the list column is the main content.
20
+ - Each row is a real button (Entity `interactive` + `onSelect`): Tab moves through rows, Enter/Space selects. The selected row is Entity `selected`, which paints the selected fill and sets aria-current; list avatars are aria-hidden so initials are not read before the name.
21
+ - The detail pane is a labelled region (`Member details`) whose title is a level-2 heading.
22
+ - On a phone, drilling in moves focus to the back button; going back returns focus to the row that was open. After a remove, focus lands on the newly selected row; after Clear search, on the search field.
19
23
 
20
24
  ## Source
21
25
 
22
26
  ```tsx
23
- import { Avatar, Badge, Button, Card, CardContent, CardHeader, CardList, CardTitle, Col, Columns, Description, DescriptionItem, Entity, Page, PageHeader, SearchInput, Stack } from "@sableui/react"
24
-
25
- const members = [
26
- { id: "u_01", name: "Ada Lovelace", handle: "@ada", role: "Owner", initials: "AL", pending: false },
27
- { id: "u_02", name: "Grace Hopper", handle: "@grace", role: "Admin", initials: "GH", pending: false },
28
- { id: "u_03", name: "Mary Shelley", handle: "@mary", role: "Member", initials: "MS", pending: false },
29
- { id: "u_04", name: "Nina Simone", handle: "@nina", role: "Member", initials: "NS", pending: false },
30
- { id: "u_05", name: "Frida Kahlo", handle: "@frida", role: "Member", initials: "FK", pending: false },
31
- { id: "u_06", name: "hopper@navy.mil", handle: "invited 2 days ago", role: "Member", initials: "?", pending: true },
27
+ import { useState, useEffect, useRef } from "react"
28
+ import { Avatar, Badge, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, Col, Columns, DestructiveActionModal, EmptyState, Entity, Field, Icon, Page, PageHeader, Row, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, Stack, StatusDot } from "@sableui/react"
29
+
30
+ type Role = "owner" | "admin" | "editor" | "viewer"
31
+
32
+ const roles: { value: Role; label: string }[] = [
33
+ { value: "owner", label: "Owner" },
34
+ { value: "admin", label: "Admin" },
35
+ { value: "editor", label: "Editor" },
36
+ { value: "viewer", label: "Viewer" },
37
+ ]
38
+
39
+ const roleLabel: Record<Role, string> = {
40
+ owner: "Owner",
41
+ admin: "Admin",
42
+ editor: "Editor",
43
+ viewer: "Viewer",
44
+ }
45
+
46
+ const roleSummary: Record<Role, string> = {
47
+ owner: "Full access, including payouts and billing. Transfer ownership in studio settings.",
48
+ admin: "Manages members, editions and payouts.",
49
+ editor: "Drafts, prices and releases editions.",
50
+ viewer: "Sees editions and sales, but can't change them.",
51
+ }
52
+
53
+ type Activity = { id: string; icon: string; text: string; when: string }
54
+
55
+ type Member = {
56
+ id: string
57
+ name: string
58
+ email: string
59
+ initials: string
60
+ role: Role
61
+ pending: boolean
62
+ handle?: string
63
+ joined?: string
64
+ lastActive?: string
65
+ editions?: number
66
+ invitedBy?: string
67
+ sent?: string
68
+ expires?: string
69
+ resent?: boolean
70
+ activity: Activity[]
71
+ }
72
+
73
+ const seats = 10
74
+
75
+ const initialMembers: Member[] = [
76
+ {
77
+ id: "u_01", name: "Ada Lovelace", handle: "@ada", email: "ada@atelier.art", initials: "AL", role: "owner", pending: false,
78
+ joined: "Mar 4, 2024", lastActive: "Online now", editions: 12,
79
+ activity: [
80
+ { id: "a1", icon: "gem", text: "Released Analytical Engine No. 3 — edition of 50", when: "2 hours ago" },
81
+ { id: "a2", icon: "mail", text: "Invited louise@bourgeois.studio as Editor", when: "2 days ago" },
82
+ ],
83
+ },
84
+ {
85
+ id: "u_02", name: "Grace Hopper", handle: "@grace", email: "grace@atelier.art", initials: "GH", role: "admin", pending: false,
86
+ joined: "Mar 9, 2024", lastActive: "20 minutes ago", editions: 4,
87
+ activity: [
88
+ { id: "a1", icon: "user", text: "Changed Frida Kahlo's role to Viewer", when: "Yesterday" },
89
+ { id: "a2", icon: "edit", text: "Updated pricing on First Compiler — edition of 10", when: "3 days ago" },
90
+ { id: "a3", icon: "gem", text: "Released First Compiler — edition of 10", when: "Sep 28" },
91
+ ],
92
+ },
93
+ {
94
+ id: "u_03", name: "Mary Shelley", handle: "@mary", email: "mary@atelier.art", initials: "MS", role: "editor", pending: false,
95
+ joined: "Jun 18, 2024", lastActive: "3 hours ago", editions: 7,
96
+ activity: [
97
+ { id: "a1", icon: "calendar", text: "Scheduled Frankenstein Studies for Oct 14", when: "3 hours ago" },
98
+ { id: "a2", icon: "gem", text: "Released The Last Man — edition of 25", when: "Sep 21" },
99
+ ],
100
+ },
101
+ {
102
+ id: "u_04", name: "Nina Simone", handle: "@nina", email: "nina@atelier.art", initials: "NS", role: "editor", pending: false,
103
+ joined: "Jan 12, 2025", lastActive: "Yesterday", editions: 3,
104
+ activity: [
105
+ { id: "a1", icon: "upload", text: "Uploaded artwork for Little Girl Blue", when: "Yesterday" },
106
+ { id: "a2", icon: "gem", text: "Released Feeling Good — edition of 100", when: "Aug 30" },
107
+ ],
108
+ },
109
+ {
110
+ id: "u_05", name: "Frida Kahlo", handle: "@frida", email: "frida@atelier.art", initials: "FK", role: "viewer", pending: false,
111
+ joined: "Jul 2, 2025", lastActive: "4 days ago", editions: 0,
112
+ activity: [{ id: "a1", icon: "comment", text: "Commented on the Las Dos Fridas draft", when: "4 days ago" }],
113
+ },
114
+ {
115
+ id: "i_01", name: "louise@bourgeois.studio", email: "louise@bourgeois.studio", initials: "", role: "editor", pending: true,
116
+ invitedBy: "Ada Lovelace", sent: "2 days ago", expires: "In 5 days",
117
+ activity: [],
118
+ },
32
119
  ]
33
120
 
34
121
  export function ListDetailPage() {
122
+ const [members, setMembers] = useState(initialMembers)
123
+ const [selectedId, setSelectedId] = useState<string | null>("u_02")
124
+ const [query, setQuery] = useState("")
125
+ // Below lg the list and the detail share one column: selecting drills in.
126
+ const [showDetail, setShowDetail] = useState(false)
127
+ const [confirming, setConfirming] = useState(false)
128
+ const listRef = useRef<HTMLDivElement>(null)
129
+ const backRef = useRef<HTMLButtonElement>(null)
130
+ const searchRef = useRef<HTMLInputElement>(null)
131
+
132
+ const needle = query.trim().toLowerCase()
133
+ const visible = members.filter((m) =>
134
+ [m.name, m.email, m.handle ?? "", roleLabel[m.role]].some((v) => v.toLowerCase().includes(needle)),
135
+ )
136
+ const selected = members.find((m) => m.id === selectedId) ?? null
137
+ const active = members.filter((m) => !m.pending).length
138
+ const pending = members.length - active
139
+
140
+ // Drilling in on a phone moves focus to the detail's back control; the
141
+ // control is display:none at lg, so desktop focus stays on the list.
142
+ useEffect(() => {
143
+ if (showDetail && backRef.current?.offsetParent) backRef.current.focus()
144
+ }, [showDetail, selectedId])
145
+
146
+ function select(id: string) {
147
+ setSelectedId(id)
148
+ setShowDetail(true)
149
+ }
150
+
151
+ function focusRow(id: string | undefined) {
152
+ requestAnimationFrame(() => {
153
+ listRef.current?.querySelector<HTMLButtonElement>(`[data-member-id="${id}"] button`)?.focus()
154
+ })
155
+ }
156
+
157
+ function backToList() {
158
+ setShowDetail(false)
159
+ focusRow(selectedId ?? undefined)
160
+ }
161
+
162
+ function setRole(id: string, role: Role) {
163
+ setMembers((list) => list.map((m) => (m.id === id ? { ...m, role } : m)))
164
+ }
165
+
166
+ function resend(id: string) {
167
+ setMembers((list) =>
168
+ list.map((m) => (m.id === id ? { ...m, sent: "Just now", expires: "In 7 days", resent: true } : m)),
169
+ )
170
+ }
171
+
172
+ function remove(id: string) {
173
+ const index = members.findIndex((m) => m.id === id)
174
+ const rest = members.filter((m) => m.id !== id)
175
+ setMembers(rest)
176
+ const next = rest[Math.min(index, rest.length - 1)]?.id
177
+ setSelectedId(next ?? null)
178
+ setShowDetail(false)
179
+ // The control that opened the confirmation is gone; land on the next row.
180
+ focusRow(next)
181
+ }
182
+
35
183
  return (
36
184
  <Page as="main" max="wide" gap={4} className="py-6">
37
185
  <PageHeader
38
186
  title="Members"
39
- description="Everyone with access to this workspace."
40
- actions={<Button>Invite member</Button>}
187
+ description={`${active} members · ${pending} pending ${pending === 1 ? "invite" : "invites"} · ${members.length} of ${seats} seats used`}
188
+ actions={<Button iconStart={<Icon name="plus" />}>Invite member</Button>}
41
189
  />
42
190
  <Columns count={{ base: 1, lg: 12 }}>
43
- <Col span={{ base: 1, lg: 8 }}>
191
+ <Col span={{ base: 1, lg: 5 }} className={showDetail ? "max-lg:hidden" : undefined}>
44
192
  <Stack gap={3}>
45
- <SearchInput placeholder="Search members" aria-label="Search members" />
46
- <Card className="overflow-hidden">
47
- <CardList>
48
- {members.map((member) => (
49
- <Entity
50
- key={member.id}
51
- leading={
52
- <Avatar
53
- fallback={member.pending ? "•" : member.initials}
54
- alt={member.pending ? "" : member.name}
55
- />
56
- }
57
- title={
58
- <span className={member.pending ? "text-muted-foreground" : undefined}>
59
- {member.name}
60
- </span>
61
- }
62
- subtitle={member.handle}
63
- action={
64
- member.pending ? (
65
- <Badge>Invited</Badge>
66
- ) : (
67
- <Badge appearance="outline">{member.role}</Badge>
68
- )
69
- }
70
- />
71
- ))}
72
- </CardList>
193
+ <SearchInput
194
+ ref={searchRef}
195
+ value={query}
196
+ onValueChange={setQuery}
197
+ placeholder="Search name, email or role"
198
+ aria-label="Search members"
199
+ />
200
+ {/* sable-surface derives the rows' selected step from the card (12% ink),
201
+ so a selected row stays distinct from its muted avatar. */}
202
+ <Card ref={listRef} className="sable-surface">
203
+ {visible.length > 0 ? (
204
+ <CardList aria-label="Members">
205
+ {visible.map((m) => {
206
+ const isSelected = m.id === selectedId
207
+ return (
208
+ <Entity
209
+ key={m.id}
210
+ data-member-id={m.id}
211
+ interactive
212
+ selected={isSelected}
213
+ onSelect={() => select(m.id)}
214
+ leading={
215
+ <Avatar
216
+ fallback={m.pending ? <Icon name="mail" className="size-5" /> : m.initials}
217
+ status={m.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
218
+ // The row's title already names it: keep the initials out of the row's name.
219
+ aria-hidden
220
+ />
221
+ }
222
+ title={m.name}
223
+ subtitle={
224
+ m.pending
225
+ ? `${roleLabel[m.role]} · invite sent ${m.sent?.toLowerCase()}`
226
+ : `${roleLabel[m.role]} · ${m.handle}`
227
+ }
228
+ action={m.pending ? <Badge tone="info">Invited</Badge> : undefined}
229
+ />
230
+ )
231
+ })}
232
+ </CardList>
233
+ ) : (
234
+ <CardContent>
235
+ <EmptyState
236
+ icon={<Icon name="search" />}
237
+ title="No members found"
238
+ description={`Nobody matches “${query.trim()}”. Try a name, email or role.`}
239
+ action={
240
+ <Button variant="secondary" size="sm" onClick={() => {
241
+ setQuery("")
242
+ searchRef.current?.focus()
243
+ }}>
244
+ Clear search
245
+ </Button>
246
+ }
247
+ />
248
+ </CardContent>
249
+ )}
73
250
  </Card>
74
251
  </Stack>
75
252
  </Col>
76
- <Col span={{ base: 1, lg: 4 }}>
77
- <aside className="sticky top-6">
78
- <Card>
79
- <CardHeader>
80
- <CardTitle>Workspace</CardTitle>
81
- </CardHeader>
82
- <CardContent className="flex flex-col gap-3">
83
- {/* Label/value pairs are a Description list, not hand-built rows. */}
84
- <Description>
85
- <DescriptionItem term="Members" detail={<span className="text-mono-md">5</span>} />
86
- <DescriptionItem term="Pending invites" detail={<span className="text-mono-md">1</span>} />
87
- <DescriptionItem term="Seats used" detail={<span className="text-mono-md">6 / 10</span>} />
88
- </Description>
89
- <Button variant="outline" size="sm" className="mt-1 self-start">
90
- Manage plan
91
- </Button>
92
- </CardContent>
93
- </Card>
94
- </aside>
253
+ <Col span={{ base: 1, lg: 7 }} className={showDetail ? undefined : "max-lg:hidden"}>
254
+ <Stack role="region" aria-label="Member details" gap={3} className="lg:sticky lg:top-6">
255
+ <Button
256
+ ref={backRef}
257
+ variant="ghost"
258
+ size="sm"
259
+ iconStart={<Icon name="chevron-left" />}
260
+ onClick={backToList}
261
+ className="self-start lg:hidden"
262
+ >
263
+ All members
264
+ </Button>
265
+ {selected === null ? (
266
+ <EmptyState
267
+ surface="card"
268
+ icon={<Icon name="user" />}
269
+ title="No member selected"
270
+ description="Choose someone from the list to see their role and activity."
271
+ />
272
+ ) : (
273
+ <>
274
+ <Card>
275
+ <CardHeader>
276
+ <Row gap={2}>
277
+ <Avatar
278
+ size="lg"
279
+ fallback={selected.pending ? <Icon name="mail" className="size-6" /> : selected.initials}
280
+ status={selected.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
281
+ alt={selected.pending ? "Pending invite" : selected.name}
282
+ />
283
+ <Stack gap={0} className="min-w-0">
284
+ <CardTitle render={<h2 />} className="truncate">
285
+ {selected.name}
286
+ </CardTitle>
287
+ <CardDescription className="truncate">
288
+ {selected.pending ? "Hasn't joined yet" : `${selected.handle} · ${selected.email}`}
289
+ </CardDescription>
290
+ </Stack>
291
+ </Row>
292
+ {selected.pending ? (
293
+ <CardAction>
294
+ <Badge tone="info">Invited</Badge>
295
+ </CardAction>
296
+ ) : null}
297
+ </CardHeader>
298
+ <CardContent>
299
+ <Stack gap={5}>
300
+ <Field
301
+ label={selected.pending ? "Role on joining" : "Role"}
302
+ description={roleSummary[selected.role]}
303
+ >
304
+ <Select
305
+ items={roles}
306
+ value={selected.role}
307
+ onValueChange={(value) => setRole(selected.id, value as Role)}
308
+ disabled={selected.role === "owner"}
309
+ >
310
+ <SelectTrigger className="max-w-72" />
311
+ <SelectContent>
312
+ {roles
313
+ .filter((r) => r.value !== "owner" || selected.role === "owner")
314
+ .map((r) => (
315
+ <SelectItem key={r.value} value={r.value}>
316
+ {r.label}
317
+ </SelectItem>
318
+ ))}
319
+ </SelectContent>
320
+ </Select>
321
+ </Field>
322
+ <div className="@container">
323
+ {selected.pending ? (
324
+ <dl className="sable-list-dl sable-list-dl-2">
325
+ <dt>Sent</dt>
326
+ <dd>{selected.sent}</dd>
327
+ <dt>Expires</dt>
328
+ <dd>{selected.expires}</dd>
329
+ <dt>Invited by</dt>
330
+ <dd>{selected.invitedBy}</dd>
331
+ </dl>
332
+ ) : (
333
+ <dl className="sable-list-dl sable-list-dl-2">
334
+ <dt>Email</dt>
335
+ <dd>{selected.email}</dd>
336
+ <dt>Joined</dt>
337
+ <dd>{selected.joined}</dd>
338
+ <dt>Last active</dt>
339
+ <dd className={selected.lastActive === "Online now" ? "text-success" : undefined}>
340
+ {selected.lastActive}
341
+ </dd>
342
+ <dt>Editions released</dt>
343
+ <dd>{selected.editions}</dd>
344
+ </dl>
345
+ )}
346
+ </div>
347
+ </Stack>
348
+ </CardContent>
349
+ {selected.role !== "owner" ? (
350
+ <CardFooter>
351
+ <Row gap={2} wrap>
352
+ {selected.pending ? (
353
+ <Button
354
+ variant="secondary"
355
+ size="sm"
356
+ iconStart={<Icon name={selected.resent ? "check" : "send"} />}
357
+ disabled={selected.resent}
358
+ onClick={() => resend(selected.id)}
359
+ >
360
+ {selected.resent ? "Invite resent" : "Resend invite"}
361
+ </Button>
362
+ ) : null}
363
+ <Button variant="outline" size="sm" onClick={() => setConfirming(true)}>
364
+ {selected.pending ? "Revoke invite" : "Remove from studio"}
365
+ </Button>
366
+ </Row>
367
+ </CardFooter>
368
+ ) : null}
369
+ </Card>
370
+ {selected.activity.length > 0 ? (
371
+ <Card>
372
+ <CardHeader>
373
+ <CardTitle render={<h3 />}>
374
+ Recent activity
375
+ </CardTitle>
376
+ </CardHeader>
377
+ <CardList>
378
+ {selected.activity.map((a) => (
379
+ <Entity
380
+ key={a.id}
381
+ leading={<Icon name={a.icon} className="text-muted-foreground" />}
382
+ title={a.text}
383
+ subtitle={a.when}
384
+ />
385
+ ))}
386
+ </CardList>
387
+ </Card>
388
+ ) : null}
389
+ <DestructiveActionModal
390
+ open={confirming}
391
+ onOpenChange={setConfirming}
392
+ title={selected.pending ? "Revoke this invite?" : `Remove ${selected.name}?`}
393
+ description={
394
+ selected.pending
395
+ ? `${selected.email} won't be able to join with the link they were sent.`
396
+ : "They lose access to the studio right away. Editions they released stay on sale."
397
+ }
398
+ confirmLabel={selected.pending ? "Revoke invite" : "Remove member"}
399
+ onConfirm={() => remove(selected.id)}
400
+ />
401
+ </>
402
+ )}
403
+ </Stack>
95
404
  </Col>
96
405
  </Columns>
97
406
  </Page>