@estiva-app/ui 0.74.0 → 0.75.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 (72) hide show
  1. package/dist/CardUnder.d.ts +10 -0
  2. package/dist/CardUnder.d.ts.map +1 -0
  3. package/dist/CommentHighlights.d.ts.map +1 -1
  4. package/dist/InlineChip.d.ts +2 -0
  5. package/dist/InlineChip.d.ts.map +1 -1
  6. package/dist/LinkCard.d.ts +17 -0
  7. package/dist/LinkCard.d.ts.map +1 -0
  8. package/dist/LinkChip.d.ts +8 -0
  9. package/dist/LinkChip.d.ts.map +1 -0
  10. package/dist/LinkParts.d.ts +45 -0
  11. package/dist/LinkParts.d.ts.map +1 -0
  12. package/dist/LinkPreview.d.ts +21 -0
  13. package/dist/LinkPreview.d.ts.map +1 -0
  14. package/dist/LinkRow.d.ts +24 -0
  15. package/dist/LinkRow.d.ts.map +1 -0
  16. package/dist/PartCard.d.ts +32 -0
  17. package/dist/PartCard.d.ts.map +1 -0
  18. package/dist/Skeleton.d.ts +12 -2
  19. package/dist/Skeleton.d.ts.map +1 -1
  20. package/dist/editor.js +42 -17
  21. package/dist/editor.js.map +3 -3
  22. package/dist/eslint/index.js +1 -0
  23. package/dist/eslint/index.js.map +2 -2
  24. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  25. package/dist/index.d.ts +8 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +480 -71
  28. package/dist/index.js.map +4 -4
  29. package/dist/linked.d.ts +168 -0
  30. package/dist/linked.d.ts.map +1 -0
  31. package/dist/linked.examples.d.ts +27 -0
  32. package/dist/linked.examples.d.ts.map +1 -0
  33. package/package.json +1 -1
  34. package/registry.json +784 -7
  35. package/src/CardUnder.mdx +53 -0
  36. package/src/CardUnder.stories.tsx +45 -0
  37. package/src/CardUnder.test.tsx +40 -0
  38. package/src/CardUnder.tsx +45 -0
  39. package/src/CommentHighlights.test.ts +56 -2
  40. package/src/CommentHighlights.ts +51 -12
  41. package/src/InlineChip.tsx +2 -0
  42. package/src/LinkCard.mdx +58 -0
  43. package/src/LinkCard.stories.tsx +68 -0
  44. package/src/LinkCard.test.tsx +103 -0
  45. package/src/LinkCard.tsx +209 -0
  46. package/src/LinkChip.mdx +51 -0
  47. package/src/LinkChip.stories.tsx +40 -0
  48. package/src/LinkChip.test.tsx +83 -0
  49. package/src/LinkChip.tsx +51 -0
  50. package/src/LinkParts.mdx +49 -0
  51. package/src/LinkParts.stories.tsx +61 -0
  52. package/src/LinkParts.tsx +147 -0
  53. package/src/LinkPreview.mdx +47 -0
  54. package/src/LinkPreview.stories.tsx +38 -0
  55. package/src/LinkPreview.tsx +55 -0
  56. package/src/LinkRow.mdx +47 -0
  57. package/src/LinkRow.stories.tsx +49 -0
  58. package/src/LinkRow.test.tsx +38 -0
  59. package/src/LinkRow.tsx +107 -0
  60. package/src/PartCard.mdx +43 -0
  61. package/src/PartCard.stories.tsx +35 -0
  62. package/src/PartCard.test.tsx +30 -0
  63. package/src/PartCard.tsx +59 -0
  64. package/src/Skeleton.tsx +7 -3
  65. package/src/eslint/no-native-title.test.ts +3 -2
  66. package/src/eslint/no-restyled-part.ts +1 -0
  67. package/src/index.ts +25 -0
  68. package/src/linked.examples.ts +107 -0
  69. package/src/linked.test.ts +58 -0
  70. package/src/linked.ts +217 -0
  71. package/src/registry/registry.test.ts +8 -8
  72. package/stories/Choosing.mdx +7 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * A link's card: what a row cannot hold. The header is the row's line (icon
3
+ * and title as the link; a ring or faces in the corner), then the key and
4
+ * what it is in, its summary, the items inside it, the newest thing said in
5
+ * it, and a footer: the pickers and the conversation count, or who and when.
6
+ * Words a person wrote draw as themselves, with their face and the time.
7
+ *
8
+ * While it loads it holds the card's own height, so what is around it does
9
+ * not jump; not available is one line.
10
+ */
11
+ import { Avatar } from './Avatar'
12
+ import { AvatarGroup } from './AvatarGroup'
13
+ import { Card } from './Card'
14
+ import { Link } from './Link'
15
+ import type { Linked, LinkedKind, LinkState, LoadingShape } from './linked'
16
+ import { CommentCount, LinkedIcon, LinkKey, LinkPickers, ProgressRing, StatusMark } from './LinkParts'
17
+ import { LinkUnavailable } from './LinkRow'
18
+ import { SkeletonBar } from './Skeleton'
19
+
20
+ export interface LinkCardProps {
21
+ link: LinkState
22
+ /** The values read as words, never pickers: the hover preview. */
23
+ readOnly?: boolean
24
+ /** No frame of its own: inside a box that draws one, as the hover preview's. */
25
+ bare?: boolean
26
+ }
27
+
28
+ export function LinkCard({ link, readOnly = false, bare = false }: LinkCardProps) {
29
+ if (link.state === 'loading') return <LinkCardLoading kind={link.kind} shape={link.shape} />
30
+ if (link.state === 'unavailable') return <LinkUnavailable kind={link.kind} noun={link.noun} openIn={link.openIn} />
31
+ const { linked } = link
32
+ const body = linked.kind === 'message' && linked.author ? <SaidBody linked={linked} /> : <CardBody linked={linked} readOnly={readOnly} />
33
+ if (bare) return <div className="flex flex-col gap-2 p-3">{body}</div>
34
+ return (
35
+ <Card fill="inset" className="flex flex-col gap-2 p-3">
36
+ {body}
37
+ </Card>
38
+ )
39
+ }
40
+
41
+ function CardBody({ linked, readOnly }: { linked: Linked; readOnly: boolean }) {
42
+ return (
43
+ <>
44
+ <div className="flex min-h-[19.6px] items-center gap-2">
45
+ {/* The icon opens it too, so the target is larger than the words. */}
46
+ <span className="flex min-w-0 flex-1 text-body-2-strong text-text-primary">
47
+ <Link href={linked.href} variant="quiet" className="flex min-w-0 flex-1 items-center gap-2">
48
+ <LinkedIcon kind={linked.kind} />
49
+ <span className="truncate">{linked.title}</span>
50
+ </Link>
51
+ </span>
52
+ {linked.done && <ProgressRing done={linked.done.done} total={linked.done.total} />}
53
+ {linked.members && <AvatarGroup members={linked.members} size={16} />}
54
+ </div>
55
+ {(linked.key || linked.subtitle) && <p className="-mt-1 pl-6 text-caption text-text-secondary">{[linked.key, linked.subtitle].filter(Boolean).join(' · ')}</p>}
56
+ {linked.summary && <p className="line-clamp-2 text-body-2 text-text-secondary">{linked.summary}</p>}
57
+ {linked.children && linked.children.length > 0 && (
58
+ <div className="flex flex-col divide-y divide-border-subtle rounded-md border border-border-subtle leaf:divide-border-strong leaf:border-border-strong">
59
+ {linked.children.map((child) => (
60
+ <div key={child.id} className="relative flex h-8 items-center gap-2 px-2 hover:bg-bg-active">
61
+ {/* The status is the row's icon: a list needs no kind icon on every row. */}
62
+ <StatusMark status={child.status} />
63
+ {child.key && <LinkKey>{child.key}</LinkKey>}
64
+ {/* Each opens on its own; the link covers its row. */}
65
+ <span className="contents text-body-2 text-text-primary">
66
+ <Link href={child.href} variant="quiet" cover truncate className="min-w-0 flex-1">
67
+ {child.title}
68
+ </Link>
69
+ </span>
70
+ {child.owner ? <Avatar name={child.owner.name} src={child.owner.picture} size={16} /> : <span className="size-4 shrink-0" aria-hidden />}
71
+ </div>
72
+ ))}
73
+ </div>
74
+ )}
75
+ {linked.latest && (
76
+ <div className="flex flex-col gap-1">
77
+ {/* "Latest" in words: the card follows it, so this is not necessarily what the link was sent about. */}
78
+ <span className="text-caption text-text-muted">Latest conversation</span>
79
+ <div className="flex gap-2 rounded-md border border-border-subtle p-2 leaf:border-border-strong">
80
+ <Avatar name={linked.latest.author.name} src={linked.latest.author.picture} size={16} />
81
+ <div className="min-w-0 flex-1">
82
+ <div className="flex items-baseline gap-2 text-caption">
83
+ <span className="text-text-primary">{linked.latest.author.name}</span>
84
+ <span className="text-text-muted">{linked.latest.at}</span>
85
+ </div>
86
+ <p className="line-clamp-2 text-body-2 text-text-secondary">{linked.latest.body}</p>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ )}
91
+ <div className="flex min-h-6 items-center gap-2 border-t border-border-subtle pt-2 leaf:border-border-strong">
92
+ {(linked.status || linked.owner) && <LinkPickers status={linked.status} owner={linked.owner} ownerLabel={linked.ownerLabel} readOnly={readOnly} />}
93
+ {/* Faces only: names would not fit once several people wrote it. */}
94
+ {linked.authors ? (
95
+ <span className="flex min-w-0 items-center gap-2 text-caption text-text-secondary">
96
+ <AvatarGroup members={linked.authors} size={16} />
97
+ {linked.updated && <span className="shrink-0">{linked.updated}</span>}
98
+ </span>
99
+ ) : (
100
+ !linked.status && !linked.owner && linked.updated && <span className="text-caption text-text-secondary">Updated {linked.updated}</span>
101
+ )}
102
+ {linked.conversation && (
103
+ <span className="ml-auto flex">
104
+ <CommentCount conversation={linked.conversation} noun={linked.conversation.noun} />
105
+ </span>
106
+ )}
107
+ </div>
108
+ </>
109
+ )
110
+ }
111
+
112
+ /** Words a person wrote, linked: their face, their name, the time as the link, and the words. */
113
+ function SaidBody({ linked }: { linked: Linked }) {
114
+ const author = linked.author!
115
+ return (
116
+ <>
117
+ <div className="flex items-center gap-2">
118
+ <Avatar name={author.name} src={author.picture} size={24} />
119
+ <span className="text-body-2-strong text-text-primary">{author.name}</span>
120
+ <span className="text-caption text-text-muted">
121
+ <Link href={linked.href} variant="quiet">
122
+ {linked.updated ?? 'Open'}
123
+ </Link>
124
+ </span>
125
+ </div>
126
+ <p className="line-clamp-4 text-body-2 text-text-primary">{linked.body ?? linked.title}</p>
127
+ </>
128
+ )
129
+ }
130
+
131
+ /** Each kind's usual card, for a placeholder when the app remembers nothing of it. */
132
+ const KIND_SHAPE: Record<LinkedKind, LoadingShape> = {
133
+ issue: { subtitle: true, summary: true, footer: 'pickers' },
134
+ project: { subtitle: false, summary: true, rows: 3, ring: true, footer: 'pickers' },
135
+ topic: { summary: true, latest: true, footer: 'line' },
136
+ page: { subtitle: true, summary: true, footer: 'people' },
137
+ file: { summary: false, footer: 'line' },
138
+ message: { summary: true, footer: 'none' },
139
+ }
140
+
141
+ export interface LinkCardLoadingProps {
142
+ kind: LinkedKind
143
+ /** What the card will hold (`loadingShapeOf` from the last time it drew); the kind's usual card otherwise. */
144
+ shape?: LoadingShape
145
+ }
146
+
147
+ /** The card it will become, drawn in bars at the card's own height. */
148
+ export function LinkCardLoading({ kind, shape }: LinkCardLoadingProps) {
149
+ const { subtitle = false, summary = true, rows = 0, latest = false, ring = false, footer = 'line' } = shape ?? KIND_SHAPE[kind]
150
+ return (
151
+ <div className="animate-skeleton-in" aria-busy>
152
+ <Card fill="inset" aria-hidden className="flex flex-col gap-2 p-3">
153
+ <div className="flex h-[19.6px] items-center gap-2">
154
+ <LinkedIcon kind={kind} />
155
+ <SkeletonBar on="inset" className="h-3.5 w-1/2" />
156
+ {ring && <SkeletonBar on="inset" shape="round" className="ml-auto size-4" />}
157
+ </div>
158
+ {subtitle && (
159
+ <div className="-mt-1 flex h-[14.4px] items-center pl-6">
160
+ <SkeletonBar on="inset" className="h-2.5 w-20" />
161
+ </div>
162
+ )}
163
+ {summary && (
164
+ <div className="flex h-[19.6px] items-center">
165
+ <SkeletonBar on="inset" className="h-3 w-4/5" />
166
+ </div>
167
+ )}
168
+ {rows > 0 && (
169
+ <div className="flex flex-col divide-y divide-border-subtle rounded-md border border-border-subtle leaf:divide-border-strong leaf:border-border-strong">
170
+ {Array.from({ length: rows }, (_, i) => (
171
+ <div key={i} className="flex h-8 items-center px-2">
172
+ <SkeletonBar on="inset" className="h-3 w-1/3" />
173
+ </div>
174
+ ))}
175
+ </div>
176
+ )}
177
+ {latest && (
178
+ <div className="flex flex-col gap-1">
179
+ <div className="flex h-[14.4px] items-center">
180
+ <SkeletonBar on="inset" className="h-2.5 w-28" />
181
+ </div>
182
+ <div className="flex h-[71.6px] flex-col justify-center gap-2 rounded-md border border-border-subtle p-2 leaf:border-border-strong">
183
+ <SkeletonBar on="inset" className="h-2.5 w-24" />
184
+ <SkeletonBar on="inset" className="h-3 w-full" />
185
+ <SkeletonBar on="inset" className="h-3 w-3/5" />
186
+ </div>
187
+ </div>
188
+ )}
189
+ {footer !== 'none' && (
190
+ <div className="flex min-h-6 items-center gap-2 border-t border-border-subtle pt-2 leaf:border-border-strong">
191
+ {footer === 'pickers' ? (
192
+ <>
193
+ <SkeletonBar on="inset" shape="control" className="h-6 w-24" />
194
+ <SkeletonBar on="inset" shape="control" className="h-6 w-28" />
195
+ </>
196
+ ) : footer === 'people' ? (
197
+ <>
198
+ <SkeletonBar on="inset" shape="round" className="size-4" />
199
+ <SkeletonBar on="inset" className="h-3 w-40" />
200
+ </>
201
+ ) : (
202
+ <SkeletonBar on="inset" className="h-3 w-40" />
203
+ )}
204
+ </div>
205
+ )}
206
+ </Card>
207
+ </div>
208
+ )
209
+ }
@@ -0,0 +1,51 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as LinkChipStories from './LinkChip.stories'
3
+
4
+ <Meta of={LinkChipStories} />
5
+
6
+ # LinkChip
7
+
8
+ A link in a sentence: its icon, its key, its title cut short, and its status as a shape.
9
+
10
+ <Canvas of={LinkChipStories.InASentence} />
11
+
12
+ ## When
13
+
14
+ - **ready** — a link the app has looked up. Hovering it shows its card (**LinkPreview**); a tap or a click opens it.
15
+ - **loading** — still being looked up: the icon, which the address already says, and a bar.
16
+ - **unavailable** — deleted, or not the reader's to see. Both say "Item, not available" in the app's word for it.
17
+ - `preview={false}` — no card on hover, where a card would cover what the reader is choosing from.
18
+
19
+ <Canvas of={LinkChipStories.EachKind} />
20
+
21
+ ## When not
22
+
23
+ - A link alone on its line → **LinkRow**.
24
+ - The one card under a piece of text → **CardUnder**.
25
+ - A person, a place or a label with no lookup behind it → **InlineChip**.
26
+
27
+ ## How
28
+
29
+ ```tsx
30
+ import { LinkChip, type LinkState } from '@estiva-app/ui'
31
+
32
+ // The app looks the link up and hands in where that stands.
33
+ const link: LinkState = found ? { state: 'ready', linked: found } : gone ? { state: 'unavailable', kind: 'issue', noun: 'Item' } : { state: 'loading', kind: 'issue' }
34
+ <LinkChip link={link} />
35
+ ```
36
+
37
+ - **The app looks it up; the chip draws.** `linked` (`Linked`, from `@estiva-app/ui`) carries the words, the `href`, the status and the owner as the app's lookup found them. The chip fetches nothing.
38
+ - `linked.href` opens it. An address in this app goes through `AppProvider`'s `navigate`, so the page does not reload.
39
+ - Words a person wrote (`kind: 'message'` with an `author`) lead with their face and read "Name: words".
40
+
41
+ ## What it owns
42
+
43
+ | It does | So you never write |
44
+ |---|---|
45
+ | Shows the card on hover, and none on a screen that cannot hover | a hover card of your own, a touch check |
46
+ | Cuts a long title short | a truncation of your own |
47
+ | Says "not available" in words, quieter than a link | a missing-reference chip of your own |
48
+
49
+ ## Props
50
+
51
+ <Controls of={LinkChipStories.InASentence} />
@@ -0,0 +1,40 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { LinkChip } from './LinkChip'
3
+ import { GROUP, ITEM, LOADING, PAGE, PLACE, ready, SAID, UNAVAILABLE } from './linked.examples'
4
+
5
+ const meta = {
6
+ title: 'Components/LinkChip',
7
+ component: LinkChip,
8
+ parameters: { layout: 'padded' },
9
+ args: { link: ready(ITEM) },
10
+ } satisfies Meta<typeof LinkChip>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ /** In a sentence. Hover it for its card. */
16
+ export const InASentence: Story = {
17
+ render: (args) => (
18
+ <p className="max-w-[560px] text-body-2 text-text-primary">
19
+ A sentence that links <LinkChip {...args} /> in the middle of its words, and goes on after it.
20
+ </p>
21
+ ),
22
+ }
23
+
24
+ /** Every kind, by its icon; words a person wrote lead with their face. */
25
+ export const EachKind: Story = {
26
+ render: () => (
27
+ <p className="max-w-[560px] text-body-2 leading-7 text-text-primary">
28
+ <LinkChip link={ready(ITEM)} /> <LinkChip link={ready(GROUP)} /> <LinkChip link={ready(PLACE)} /> <LinkChip link={ready(PAGE)} /> <LinkChip link={ready(SAID)} />
29
+ </p>
30
+ ),
31
+ }
32
+
33
+ /** Still being looked up: the icon, known from the address, and a bar. */
34
+ export const Loading: Story = { args: { link: LOADING } }
35
+
36
+ /** Deleted, or not this reader's to see: said in words. */
37
+ export const NotAvailable: Story = { args: { link: UNAVAILABLE } }
38
+
39
+ /** No card on hover: where a card would cover what the reader is choosing from. */
40
+ export const WithoutPreview: Story = { args: { preview: false } }
@@ -0,0 +1,83 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the LinkChip and LinkPreview pages claim, pinned: what each state says,
4
+ * where the chip leads, and that a screen that cannot hover gets no preview.
5
+ */
6
+ import { act, cleanup, render, screen } from '@testing-library/react'
7
+ import userEvent from '@testing-library/user-event'
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9
+ import { LinkChip } from './LinkChip'
10
+ import { ITEM, LOADING, ready, SAID, UNAVAILABLE } from './linked.examples'
11
+
12
+ /** A screen that can hover, or one that cannot (a phone, a tablet). */
13
+ function screenThatCanHover(canHover: boolean) {
14
+ vi.stubGlobal('matchMedia', (query: string) => ({
15
+ matches: query === '(hover: hover)' ? canHover : false,
16
+ media: query,
17
+ addEventListener: () => {},
18
+ removeEventListener: () => {},
19
+ }))
20
+ }
21
+
22
+ beforeEach(() => {
23
+ vi.useFakeTimers({ shouldAdvanceTime: true })
24
+ })
25
+
26
+ afterEach(() => {
27
+ cleanup()
28
+ vi.useRealTimers()
29
+ vi.unstubAllGlobals()
30
+ })
31
+
32
+ /** Rest the pointer on the chip past the preview's delay. */
33
+ async function hoverChip(name: RegExp) {
34
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
35
+ await user.hover(screen.getByRole('link', { name }))
36
+ await act(async () => {
37
+ vi.advanceTimersByTime(1000)
38
+ })
39
+ }
40
+
41
+ describe('LinkChip', () => {
42
+ it('leads to what it links, with its key and title', () => {
43
+ render(<LinkChip link={ready(ITEM)} preview={false} />)
44
+ const link = screen.getByRole('link', { name: /ABC-2/ })
45
+ expect(link.getAttribute('href')).toBe('#item')
46
+ expect(link.textContent).toContain(ITEM.title)
47
+ })
48
+
49
+ it('says a person’s words after their name', () => {
50
+ render(<LinkChip link={ready(SAID)} preview={false} />)
51
+ expect(screen.getByRole('link').textContent).toContain(`Person one: ${SAID.body}`)
52
+ })
53
+
54
+ it('while it loads, is busy and leads nowhere yet', () => {
55
+ const { container } = render(<LinkChip link={LOADING} />)
56
+ expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()
57
+ expect(screen.queryByRole('link')).toBeNull()
58
+ })
59
+
60
+ it('says “not available” in words, and leads nowhere', () => {
61
+ render(<LinkChip link={UNAVAILABLE} />)
62
+ expect(screen.getByText('Item, not available')).toBeTruthy()
63
+ expect(screen.queryByRole('link')).toBeNull()
64
+ })
65
+
66
+ it('shows its card, read only, when the pointer rests on it', async () => {
67
+ screenThatCanHover(true)
68
+ render(<LinkChip link={ready(ITEM)} />)
69
+ await hoverChip(/ABC-2/)
70
+ expect(screen.getByText(ITEM.summary!)).toBeTruthy()
71
+ // Read only: the values are words, never pickers, though this reader may change them.
72
+ expect(screen.queryByRole('combobox')).toBeNull()
73
+ expect(screen.getByText('Person one')).toBeTruthy()
74
+ })
75
+
76
+ it('shows no card on a screen that cannot hover, where a tap opens it', async () => {
77
+ screenThatCanHover(false)
78
+ render(<LinkChip link={ready(ITEM)} />)
79
+ await hoverChip(/ABC-2/)
80
+ expect(screen.queryByText(ITEM.summary!)).toBeNull()
81
+ expect(screen.getByRole('link', { name: /ABC-2/ }).getAttribute('href')).toBe('#item')
82
+ })
83
+ })
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A link in running text: the icon (or the author's face, for words a person
3
+ * wrote), the key, the title cut short, the status as its shape. Loading
4
+ * keeps the icon, since the kind is known from the address; not available says
5
+ * so in words. Hovering it shows its card; a tap opens it.
6
+ */
7
+ import { Avatar } from './Avatar'
8
+ import { InlineChip } from './InlineChip'
9
+ import type { LinkState } from './linked'
10
+ import { chosenOf } from './linked'
11
+ import { LinkedIcon, LinkKey, StatusMark } from './LinkParts'
12
+ import { LinkPreview } from './LinkPreview'
13
+ import { SkeletonBar } from './Skeleton'
14
+
15
+ export interface LinkChipProps {
16
+ link: LinkState
17
+ /** Hovering shows its card. Off where a card would cover what the reader is choosing from. */
18
+ preview?: boolean
19
+ }
20
+
21
+ export function LinkChip({ link, preview = true }: LinkChipProps) {
22
+ if (link.state === 'loading') {
23
+ return (
24
+ <InlineChip icon={<LinkedIcon kind={link.kind} size={14} tone="inherit" />} aria-busy>
25
+ <SkeletonBar className="h-2.5 w-16" />
26
+ </InlineChip>
27
+ )
28
+ }
29
+ if (link.state === 'unavailable') {
30
+ return (
31
+ <InlineChip tone="muted" icon={<LinkedIcon kind={link.kind} size={14} tone="inherit" />}>
32
+ {link.noun}, not available
33
+ </InlineChip>
34
+ )
35
+ }
36
+ const { linked } = link
37
+ const status = chosenOf(linked.status)
38
+ const said = linked.kind === 'message' && linked.author
39
+ const chip = (
40
+ <InlineChip
41
+ icon={said ? <Avatar name={said.name} src={said.picture} size={16} /> : <LinkedIcon kind={linked.kind} size={14} tone="inherit" />}
42
+ href={linked.href}
43
+ className="max-w-[28ch]"
44
+ >
45
+ {linked.key && <LinkKey>{linked.key}</LinkKey>}
46
+ <span className="truncate">{said ? `${said.name}: ${linked.body ?? linked.title}` : linked.title}</span>
47
+ {status?.colour && <StatusMark status={{ label: status.label, colour: status.colour }} />}
48
+ </InlineChip>
49
+ )
50
+ return preview ? <LinkPreview linked={linked}>{chip}</LinkPreview> : chip
51
+ }
@@ -0,0 +1,49 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as LinkPartsStories from './LinkParts.stories'
3
+
4
+ <Meta of={LinkPartsStories} />
5
+
6
+ # LinkParts
7
+
8
+ The small parts the link widgets are built from: the pickers, the conversation count, a status's shape and a progress ring.
9
+
10
+ <Canvas of={LinkPartsStories.Pickers} />
11
+
12
+ ## When
13
+
14
+ - **LinkPickers** — a status and an owner, changed in place. A picker with no `onChange`, or `readOnly`, reads as words in the same place.
15
+ - **CommentCount** — how much was said, as a link to where it is read; brighter, with a dot, when there is something unread for a member.
16
+ - **StatusMark** — a status as its shape, which reads without colour; its words on hover.
17
+ - **ProgressRing** — how far along, as a ring and a count.
18
+ - **LinkedIcon**, **LinkKey** — the icon for what a link stands for, and its key.
19
+
20
+ <Canvas of={LinkPartsStories.PickersReadOnly} />
21
+ <Canvas of={LinkPartsStories.Conversation} />
22
+ <Canvas of={LinkPartsStories.Marks} />
23
+
24
+ ## When not
25
+
26
+ - A whole link → **LinkChip**, **LinkRow**, **LinkCard**.
27
+ - A choice in a form → **Select**.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { LinkPickers, CommentCount } from '@estiva-app/ui'
33
+
34
+ <LinkPickers status={{ value: statusKey, options: statuses, onChange: setStatus }} owner={{ value: ownerKey, options: people }} ownerLabel="Owner" />
35
+ <CommentCount conversation={{ count: 4, href: conversationHref, unread: isMemberWithUnread }} />
36
+ ```
37
+
38
+ - `unread` only for a member of the conversation: anyone else sees the count alone.
39
+
40
+ ## What it owns
41
+
42
+ | It does | So you never write |
43
+ |---|---|
44
+ | Draws a value as words where it may not be changed | a disabled select |
45
+ | Says the count and whether it is unread to a screen reader | an `aria-label` of your own |
46
+
47
+ ## Props
48
+
49
+ <Controls of={LinkPartsStories.PickersReadOnly} />
@@ -0,0 +1,61 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { PEOPLE, STATUS_OPTIONS } from './linked.examples'
4
+ import { CommentCount, LinkPickers, ProgressRing, StatusMark } from './LinkParts'
5
+
6
+ const meta = {
7
+ title: 'Components/LinkParts',
8
+ component: LinkPickers,
9
+ parameters: { layout: 'padded' },
10
+ } satisfies Meta<typeof LinkPickers>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ /** A status and an owner, changed from the card. */
16
+ export const Pickers: Story = {
17
+ render: () => {
18
+ const [status, setStatus] = useState('todo')
19
+ const [owner, setOwner] = useState<string | undefined>(undefined)
20
+ return (
21
+ <LinkPickers
22
+ status={{ value: status, options: STATUS_OPTIONS, onChange: setStatus }}
23
+ owner={{ value: owner, options: PEOPLE, onChange: setOwner }}
24
+ ownerLabel="Owner"
25
+ />
26
+ )
27
+ },
28
+ }
29
+
30
+ /** To a reader who may not change them, and in the hover preview: the same values, as words. */
31
+ export const PickersReadOnly: Story = {
32
+ args: { status: { value: 'doing', options: STATUS_OPTIONS }, owner: { value: 'b', options: PEOPLE } },
33
+ }
34
+
35
+ /** Nobody owns it yet, read only. */
36
+ export const NoOwner: Story = {
37
+ args: { status: { value: 'todo', options: STATUS_OPTIONS }, owner: { value: undefined, options: PEOPLE } },
38
+ }
39
+
40
+ /** The count of what was said, opening where it is read. With something unread, to a member: brighter, with a dot. */
41
+ export const Conversation: Story = {
42
+ render: () => (
43
+ <div className="flex items-center gap-4">
44
+ <CommentCount conversation={{ count: 4, href: '#conversation' }} />
45
+ <CommentCount conversation={{ count: 5, href: '#conversation', unread: true }} />
46
+ </div>
47
+ ),
48
+ }
49
+
50
+ /** A status as its shape, and progress as a ring. */
51
+ export const Marks: Story = {
52
+ render: () => (
53
+ <div className="flex items-center gap-4">
54
+ {STATUS_OPTIONS.map((option) => (
55
+ <StatusMark key={option.value} status={{ label: option.label, colour: option.colour! }} />
56
+ ))}
57
+ <ProgressRing done={2} total={5} />
58
+ <ProgressRing done={5} total={5} />
59
+ </div>
60
+ ),
61
+ }