@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.
- package/dist/CardUnder.d.ts +10 -0
- package/dist/CardUnder.d.ts.map +1 -0
- package/dist/CommentHighlights.d.ts.map +1 -1
- package/dist/InlineChip.d.ts +2 -0
- package/dist/InlineChip.d.ts.map +1 -1
- package/dist/LinkCard.d.ts +17 -0
- package/dist/LinkCard.d.ts.map +1 -0
- package/dist/LinkChip.d.ts +8 -0
- package/dist/LinkChip.d.ts.map +1 -0
- package/dist/LinkParts.d.ts +45 -0
- package/dist/LinkParts.d.ts.map +1 -0
- package/dist/LinkPreview.d.ts +21 -0
- package/dist/LinkPreview.d.ts.map +1 -0
- package/dist/LinkRow.d.ts +24 -0
- package/dist/LinkRow.d.ts.map +1 -0
- package/dist/PartCard.d.ts +32 -0
- package/dist/PartCard.d.ts.map +1 -0
- package/dist/Skeleton.d.ts +12 -2
- package/dist/Skeleton.d.ts.map +1 -1
- package/dist/editor.js +42 -17
- package/dist/editor.js.map +3 -3
- package/dist/eslint/index.js +1 -0
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +480 -71
- package/dist/index.js.map +4 -4
- package/dist/linked.d.ts +168 -0
- package/dist/linked.d.ts.map +1 -0
- package/dist/linked.examples.d.ts +27 -0
- package/dist/linked.examples.d.ts.map +1 -0
- package/package.json +1 -1
- package/registry.json +784 -7
- package/src/CardUnder.mdx +53 -0
- package/src/CardUnder.stories.tsx +45 -0
- package/src/CardUnder.test.tsx +40 -0
- package/src/CardUnder.tsx +45 -0
- package/src/CommentHighlights.test.ts +56 -2
- package/src/CommentHighlights.ts +51 -12
- package/src/InlineChip.tsx +2 -0
- package/src/LinkCard.mdx +58 -0
- package/src/LinkCard.stories.tsx +68 -0
- package/src/LinkCard.test.tsx +103 -0
- package/src/LinkCard.tsx +209 -0
- package/src/LinkChip.mdx +51 -0
- package/src/LinkChip.stories.tsx +40 -0
- package/src/LinkChip.test.tsx +83 -0
- package/src/LinkChip.tsx +51 -0
- package/src/LinkParts.mdx +49 -0
- package/src/LinkParts.stories.tsx +61 -0
- package/src/LinkParts.tsx +147 -0
- package/src/LinkPreview.mdx +47 -0
- package/src/LinkPreview.stories.tsx +38 -0
- package/src/LinkPreview.tsx +55 -0
- package/src/LinkRow.mdx +47 -0
- package/src/LinkRow.stories.tsx +49 -0
- package/src/LinkRow.test.tsx +38 -0
- package/src/LinkRow.tsx +107 -0
- package/src/PartCard.mdx +43 -0
- package/src/PartCard.stories.tsx +35 -0
- package/src/PartCard.test.tsx +30 -0
- package/src/PartCard.tsx +59 -0
- package/src/Skeleton.tsx +7 -3
- package/src/eslint/no-native-title.test.ts +3 -2
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/index.ts +25 -0
- package/src/linked.examples.ts +107 -0
- package/src/linked.test.ts +58 -0
- package/src/linked.ts +217 -0
- package/src/registry/registry.test.ts +8 -8
- package/stories/Choosing.mdx +7 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The small parts every link widget is built from: the icon for what a link
|
|
3
|
+
* stands for, a status as its shape, progress as a ring, the conversation
|
|
4
|
+
* count with its unread dot, and the pickers. LinkChip, LinkRow and LinkCard
|
|
5
|
+
* place them; an app may place them too.
|
|
6
|
+
*/
|
|
7
|
+
import { IconCircle, IconCircleCheck, IconCircleDashed, IconCircleHalf2, IconCircleX, IconFileText, IconMessage2 } from '@tabler/icons-react'
|
|
8
|
+
import { Avatar } from './Avatar'
|
|
9
|
+
import { cn } from './cn'
|
|
10
|
+
import { KindIcon } from './KindIcon'
|
|
11
|
+
import { Link } from './Link'
|
|
12
|
+
import { chosenOf, type LinkedConversation, type LinkedKind, type LinkedPicker, type LinkedStatus, type StatusColour } from './linked'
|
|
13
|
+
import { Select } from './Select'
|
|
14
|
+
import { WithTooltip } from './Tooltip'
|
|
15
|
+
import { UnreadDot } from './UnreadDot'
|
|
16
|
+
|
|
17
|
+
const STATUS_ICON: Record<StatusColour, { Icon: typeof IconCircle; tone: string }> = {
|
|
18
|
+
neutral: { Icon: IconCircleDashed, tone: 'text-text-secondary' },
|
|
19
|
+
blue: { Icon: IconCircleHalf2, tone: 'text-info-default' },
|
|
20
|
+
green: { Icon: IconCircleCheck, tone: 'text-success-default' },
|
|
21
|
+
muted: { Icon: IconCircleX, tone: 'text-text-muted' },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A status's shape alone, beside words that already name it. */
|
|
25
|
+
function StatusIcon({ colour }: { colour: StatusColour }) {
|
|
26
|
+
const { Icon, tone } = STATUS_ICON[colour]
|
|
27
|
+
return <Icon size={14} stroke={1.5} className={cn('shrink-0', tone)} aria-hidden />
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A status as its shape, which reads without colour; its words on hover and to a screen reader. */
|
|
31
|
+
export function StatusMark({ status }: { status: LinkedStatus }) {
|
|
32
|
+
return (
|
|
33
|
+
<WithTooltip label={status.label} inline wrapperClassName="inline-flex shrink-0 items-center">
|
|
34
|
+
<StatusIcon colour={status.colour} />
|
|
35
|
+
<span className="sr-only">{status.label}</span>
|
|
36
|
+
</WithTooltip>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The icon for what a link stands for. A page of text has its own. */
|
|
41
|
+
export function LinkedIcon({ kind, size = 16, tone = 'secondary' }: { kind: LinkedKind; size?: 14 | 16; tone?: 'secondary' | 'inherit' }) {
|
|
42
|
+
if (kind === 'page') return <IconFileText size={size} stroke={1.5} className={cn('shrink-0', tone === 'secondary' && 'text-text-secondary')} aria-hidden />
|
|
43
|
+
return <KindIcon kind={kind} size={size} tone={tone} />
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A key, the way people say it ("ABC-2"): before the title, quieter than it. */
|
|
47
|
+
export function LinkKey({ children }: { children: string }) {
|
|
48
|
+
return <span className="shrink-0 text-caption tabular-nums text-text-muted">{children}</span>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Progress as a small ring, with the count beside it. */
|
|
52
|
+
export function ProgressRing({ done, total }: { done: number; total: number }) {
|
|
53
|
+
const size = 16
|
|
54
|
+
const stroke = 2
|
|
55
|
+
const r = (size - stroke) / 2
|
|
56
|
+
const c = 2 * Math.PI * r
|
|
57
|
+
const share = total > 0 ? Math.min(done, total) / total : 0
|
|
58
|
+
return (
|
|
59
|
+
<WithTooltip label={`${done} of ${total} done`} inline wrapperClassName="inline-flex shrink-0">
|
|
60
|
+
<span className="inline-flex shrink-0 items-center gap-1 text-caption tabular-nums text-text-secondary">
|
|
61
|
+
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0 -rotate-90" aria-hidden>
|
|
62
|
+
<circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-border-default" />
|
|
63
|
+
<circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} strokeLinecap="round" strokeDasharray={`${c * share} ${c}`} className="stroke-success-default" />
|
|
64
|
+
</svg>
|
|
65
|
+
{done}/{total}
|
|
66
|
+
</span>
|
|
67
|
+
</WithTooltip>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The count of what was said, a link to where it is read. With something
|
|
73
|
+
* unread there the words are brighter and a dot follows. It sits above a
|
|
74
|
+
* row's covering link, so the row opens the thing and the count its
|
|
75
|
+
* conversation.
|
|
76
|
+
*/
|
|
77
|
+
export function CommentCount({ conversation, noun = 'comments' }: { conversation: LinkedConversation; noun?: string }) {
|
|
78
|
+
const { count, href, unread = false } = conversation
|
|
79
|
+
return (
|
|
80
|
+
<span className={cn('relative z-10 -mx-1 inline-flex shrink-0 rounded-sm px-1 text-caption tabular-nums hover:bg-bg-active', unread ? 'font-medium text-text-primary' : 'text-text-secondary')}>
|
|
81
|
+
<Link href={href} variant="plain" className="inline-flex items-center gap-1">
|
|
82
|
+
<IconMessage2 size={14} stroke={1.5} aria-hidden />
|
|
83
|
+
{count}
|
|
84
|
+
<span className="sr-only">{unread ? `${noun}, unread — open the conversation` : `${noun} — open the conversation`}</span>
|
|
85
|
+
{unread && <UnreadDot className="h-auto w-auto" />}
|
|
86
|
+
</Link>
|
|
87
|
+
</span>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface LinkPickersProps {
|
|
92
|
+
status?: LinkedPicker
|
|
93
|
+
owner?: LinkedPicker
|
|
94
|
+
/** The owner's word: "Assignee", "Lead". */
|
|
95
|
+
ownerLabel?: string
|
|
96
|
+
/** Draws the values as words even where they could be changed: the hover preview. */
|
|
97
|
+
readOnly?: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A status and an owner, where the reader can change them from the card. A
|
|
102
|
+
* picker whose app passed no `onChange` reads as words in the same place: the
|
|
103
|
+
* reader may not change it. The hover preview is always words.
|
|
104
|
+
*/
|
|
105
|
+
export function LinkPickers({ status, owner, ownerLabel = 'Owner', readOnly = false }: LinkPickersProps) {
|
|
106
|
+
const statusNow = chosenOf(status)
|
|
107
|
+
const ownerNow = chosenOf(owner)
|
|
108
|
+
const statusWords = status && (readOnly || !status.onChange)
|
|
109
|
+
const ownerWords = owner && (readOnly || !owner.onChange)
|
|
110
|
+
return (
|
|
111
|
+
<span className="flex h-6 min-w-0 items-center gap-2 text-caption text-text-secondary">
|
|
112
|
+
{status &&
|
|
113
|
+
(statusWords ? (
|
|
114
|
+
<span className="inline-flex shrink-0 items-center gap-1">
|
|
115
|
+
{statusNow?.colour && <StatusIcon colour={statusNow.colour} />}
|
|
116
|
+
{statusNow?.label ?? 'No status'}
|
|
117
|
+
</span>
|
|
118
|
+
) : (
|
|
119
|
+
<Select
|
|
120
|
+
size="small"
|
|
121
|
+
className="w-auto"
|
|
122
|
+
value={status.value ?? ''}
|
|
123
|
+
onChange={(value) => status.onChange?.(value)}
|
|
124
|
+
ariaLabel="Status"
|
|
125
|
+
options={status.options.map((option) => ({ value: option.value, label: option.label, leading: option.colour && <StatusIcon colour={option.colour} /> }))}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
{owner &&
|
|
129
|
+
(ownerWords ? (
|
|
130
|
+
<span className="inline-flex min-w-0 items-center gap-1">
|
|
131
|
+
<Avatar name={ownerNow?.label} src={ownerNow?.picture} size={16} />
|
|
132
|
+
<span className="truncate">{ownerNow?.label ?? 'Unassigned'}</span>
|
|
133
|
+
</span>
|
|
134
|
+
) : (
|
|
135
|
+
<Select
|
|
136
|
+
size="small"
|
|
137
|
+
className="w-auto"
|
|
138
|
+
value={owner.value ?? ''}
|
|
139
|
+
placeholder="Unassigned"
|
|
140
|
+
onChange={(value) => owner.onChange?.(value)}
|
|
141
|
+
ariaLabel={ownerLabel}
|
|
142
|
+
options={owner.options.map((option) => ({ value: option.value, label: option.label, leading: <Avatar name={option.label} src={option.picture} size={16} /> }))}
|
|
143
|
+
/>
|
|
144
|
+
))}
|
|
145
|
+
</span>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as LinkPreviewStories from './LinkPreview.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={LinkPreviewStories} />
|
|
5
|
+
|
|
6
|
+
# LinkPreview
|
|
7
|
+
|
|
8
|
+
A link's card on hover over its chip or its row, read only, in the package's one elevated box.
|
|
9
|
+
|
|
10
|
+
<Canvas of={LinkPreviewStories.OnHover} />
|
|
11
|
+
|
|
12
|
+
## When
|
|
13
|
+
|
|
14
|
+
- **on hover** — **LinkChip** and **LinkRow** wrap themselves in it; an app wraps its own trigger only when it draws a link some other way.
|
|
15
|
+
- **open** — `LinkPreviewOpen`, the same box drawn open, for a still picture.
|
|
16
|
+
- **on a screen that cannot hover** — nothing: the trigger alone, and a tap opens the link. `useCanHover()` says which.
|
|
17
|
+
|
|
18
|
+
<Canvas of={LinkPreviewStories.Open} />
|
|
19
|
+
|
|
20
|
+
## When not
|
|
21
|
+
|
|
22
|
+
- A card that stays → **LinkCard**, or **CardUnder** under a piece of text.
|
|
23
|
+
- A preview of something that is not a link → **PreviewCard**.
|
|
24
|
+
|
|
25
|
+
## How
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { LinkPreview } from '@estiva-app/ui'
|
|
29
|
+
|
|
30
|
+
<LinkPreview linked={linked} block>
|
|
31
|
+
{yourRow}
|
|
32
|
+
</LinkPreview>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- `block` for a trigger that fills its column; a chip is inline.
|
|
36
|
+
- Nothing in it can be changed: the values are words.
|
|
37
|
+
|
|
38
|
+
## What it owns
|
|
39
|
+
|
|
40
|
+
| It does | So you never write |
|
|
41
|
+
|---|---|
|
|
42
|
+
| Waits before it opens, and closes when the pointer leaves | a hover timer |
|
|
43
|
+
| Draws no preview on a screen that cannot hover | a `matchMedia` check |
|
|
44
|
+
|
|
45
|
+
## Props
|
|
46
|
+
|
|
47
|
+
<Controls of={LinkPreviewStories.OnHover} />
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { LinkChip } from './LinkChip'
|
|
3
|
+
import { GROUP, ITEM, PLACE, ready } from './linked.examples'
|
|
4
|
+
import { LinkPreview, LinkPreviewOpen } from './LinkPreview'
|
|
5
|
+
import { LinkRow } from './LinkRow'
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Components/LinkPreview',
|
|
9
|
+
component: LinkPreview,
|
|
10
|
+
parameters: { layout: 'padded' },
|
|
11
|
+
args: { linked: ITEM, children: null },
|
|
12
|
+
} satisfies Meta<typeof LinkPreview>
|
|
13
|
+
|
|
14
|
+
export default meta
|
|
15
|
+
type Story = StoryObj<typeof meta>
|
|
16
|
+
|
|
17
|
+
/** Hover the chip or the row: the card shows, read only. On a screen that cannot hover there is none, and a tap opens the link. */
|
|
18
|
+
export const OnHover: Story = {
|
|
19
|
+
render: () => (
|
|
20
|
+
<div className="flex max-w-[480px] flex-col gap-3">
|
|
21
|
+
<p className="text-body-2 text-text-primary">
|
|
22
|
+
Hover <LinkChip link={ready(ITEM)} /> for its card.
|
|
23
|
+
</p>
|
|
24
|
+
<LinkRow link={ready(GROUP)} />
|
|
25
|
+
</div>
|
|
26
|
+
),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Drawn open, for a still picture: the values are words, never pickers. */
|
|
30
|
+
export const Open: Story = {
|
|
31
|
+
render: () => (
|
|
32
|
+
<div className="flex flex-wrap items-start gap-4">
|
|
33
|
+
<LinkPreviewOpen linked={ITEM} />
|
|
34
|
+
<LinkPreviewOpen linked={GROUP} />
|
|
35
|
+
<LinkPreviewOpen linked={PLACE} />
|
|
36
|
+
</div>
|
|
37
|
+
),
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A link's card, shown on hover over its chip or its row: read only, in the
|
|
3
|
+
* package's one elevated box. A screen that cannot hover draws no preview, and
|
|
4
|
+
* a tap opens the link (Miky, WID-2: "there's no hover on touch").
|
|
5
|
+
*/
|
|
6
|
+
import { useSyncExternalStore, type ReactNode } from 'react'
|
|
7
|
+
import { LinkCard } from './LinkCard'
|
|
8
|
+
import type { Linked } from './linked'
|
|
9
|
+
import { MenuPanel } from './Menu'
|
|
10
|
+
import { PreviewCard } from './PreviewCard'
|
|
11
|
+
|
|
12
|
+
const HOVER = '(hover: hover)'
|
|
13
|
+
|
|
14
|
+
function subscribe(onChange: () => void) {
|
|
15
|
+
if (typeof window === 'undefined' || !window.matchMedia) return () => {}
|
|
16
|
+
const query = window.matchMedia(HOVER)
|
|
17
|
+
query.addEventListener('change', onChange)
|
|
18
|
+
return () => query.removeEventListener('change', onChange)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Whether this screen can hover. Without `matchMedia` (a test, a server) it is taken to. */
|
|
22
|
+
export function useCanHover(): boolean {
|
|
23
|
+
return useSyncExternalStore(
|
|
24
|
+
subscribe,
|
|
25
|
+
() => (typeof window === 'undefined' || !window.matchMedia ? true : window.matchMedia(HOVER).matches),
|
|
26
|
+
() => true,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LinkPreviewProps {
|
|
31
|
+
linked: Linked
|
|
32
|
+
/** The trigger fills its column, as a row does; a chip is inline. */
|
|
33
|
+
block?: boolean
|
|
34
|
+
children: ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function LinkPreview({ linked, block = false, children }: LinkPreviewProps) {
|
|
38
|
+
const canHover = useCanHover()
|
|
39
|
+
if (!canHover) return <>{children}</>
|
|
40
|
+
// A row fills its column; PreviewCard's own trigger is inline and would shrink it to its words.
|
|
41
|
+
return (
|
|
42
|
+
<PreviewCard side="bottom" content={<LinkCard link={{ state: 'ready', linked }} bare readOnly />} wrapperClassName={block ? 'grid w-full min-w-0 grid-cols-[minmax(0,1fr)]' : 'max-w-full'}>
|
|
43
|
+
{children}
|
|
44
|
+
</PreviewCard>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The preview drawn open, for a still picture of it: the same box PreviewCard draws. */
|
|
49
|
+
export function LinkPreviewOpen({ linked }: { linked: Linked }) {
|
|
50
|
+
return (
|
|
51
|
+
<MenuPanel className="w-[360px] p-0">
|
|
52
|
+
<LinkCard link={{ state: 'ready', linked }} bare readOnly />
|
|
53
|
+
</MenuPanel>
|
|
54
|
+
)
|
|
55
|
+
}
|
package/src/LinkRow.mdx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as LinkRowStories from './LinkRow.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={LinkRowStories} />
|
|
5
|
+
|
|
6
|
+
# LinkRow
|
|
7
|
+
|
|
8
|
+
A link alone on its line: one 36px row for every kind, with what it has in its corner.
|
|
9
|
+
|
|
10
|
+
<Canvas of={LinkRowStories.EachKind} />
|
|
11
|
+
|
|
12
|
+
## When
|
|
13
|
+
|
|
14
|
+
- **ready** — the icon, the key, the title as the link, and in the corner its status, ring, owner, members or authors, and when it changed. Hovering it shows its card; a tap opens it.
|
|
15
|
+
- **unread** — with something unread in its conversation the count shows, with its dot. A row otherwise keeps to one line and leaves the count to the card, except where conversations are what it holds.
|
|
16
|
+
- **loading** — the row in bars, at the row's height.
|
|
17
|
+
- **unavailable** — one line, "Item — not available.", with where it may still open (`openIn`). This is also what a card draws when it is not available: **LinkUnavailable**, from this file.
|
|
18
|
+
|
|
19
|
+
<Canvas of={LinkRowStories.NotAvailable} />
|
|
20
|
+
|
|
21
|
+
## When not
|
|
22
|
+
|
|
23
|
+
- A link in a sentence → **LinkChip**.
|
|
24
|
+
- The one card under a piece of text → **CardUnder**.
|
|
25
|
+
|
|
26
|
+
## How
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { LinkRow } from '@estiva-app/ui'
|
|
30
|
+
|
|
31
|
+
<LinkRow link={{ state: 'ready', linked }} />
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- The whole row opens `linked.href`; the conversation count opens `linked.conversation.href` on its own.
|
|
35
|
+
- `linked.conversation.unread` is the app's to say, and only for a member of it.
|
|
36
|
+
|
|
37
|
+
## What it owns
|
|
38
|
+
|
|
39
|
+
| It does | So you never write |
|
|
40
|
+
|---|---|
|
|
41
|
+
| Covers the row with its link, with the count above it | a click handler on the row |
|
|
42
|
+
| Shows the card on hover, and none on a screen that cannot hover | a hover card of your own |
|
|
43
|
+
| Holds the row's height while it loads and when it is not available | a placeholder height of your own |
|
|
44
|
+
|
|
45
|
+
## Props
|
|
46
|
+
|
|
47
|
+
<Controls of={LinkRowStories.Default} />
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { GROUP, ITEM, ITEM_UNREAD, LOADING, PAGE, PLACE, ready, SAID, UNAVAILABLE } from './linked.examples'
|
|
3
|
+
import { LinkRow, LinkUnavailable } from './LinkRow'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/LinkRow',
|
|
7
|
+
component: LinkRow,
|
|
8
|
+
parameters: { layout: 'padded' },
|
|
9
|
+
args: { link: ready(ITEM) },
|
|
10
|
+
decorators: [(Story) => <div className="max-w-[480px]">{Story()}</div>],
|
|
11
|
+
} satisfies Meta<typeof LinkRow>
|
|
12
|
+
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof meta>
|
|
15
|
+
|
|
16
|
+
/** A link alone on its line. Hover it for its card. */
|
|
17
|
+
export const Default: Story = {}
|
|
18
|
+
|
|
19
|
+
/** Every kind: the corner says what each one has. */
|
|
20
|
+
export const EachKind: Story = {
|
|
21
|
+
render: () => (
|
|
22
|
+
<div className="flex flex-col gap-2">
|
|
23
|
+
<LinkRow link={ready(ITEM)} />
|
|
24
|
+
<LinkRow link={ready(GROUP)} />
|
|
25
|
+
<LinkRow link={ready(PLACE)} />
|
|
26
|
+
<LinkRow link={ready(PAGE)} />
|
|
27
|
+
<LinkRow link={ready(SAID)} />
|
|
28
|
+
</div>
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Something unread in its conversation, to a member: the count shows, with its dot. */
|
|
33
|
+
export const Unread: Story = { args: { link: ready(ITEM_UNREAD) } }
|
|
34
|
+
|
|
35
|
+
/** Still being looked up: the row in bars, at the row's height. */
|
|
36
|
+
export const Loading: Story = { args: { link: LOADING } }
|
|
37
|
+
|
|
38
|
+
/** Deleted, or not this reader's to see: one line, and where it may still open. */
|
|
39
|
+
export const NotAvailable: Story = { args: { link: UNAVAILABLE } }
|
|
40
|
+
|
|
41
|
+
/** The not-available line on its own, as a card or a part draws it too; without `openIn`, no way out. */
|
|
42
|
+
export const NotAvailableLine: Story = {
|
|
43
|
+
render: () => (
|
|
44
|
+
<div className="flex flex-col gap-2">
|
|
45
|
+
<LinkUnavailable kind="issue" noun="Item" openIn={{ app: 'App one', href: '#elsewhere' }} />
|
|
46
|
+
<LinkUnavailable kind="page" noun="Page" />
|
|
47
|
+
</div>
|
|
48
|
+
),
|
|
49
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/** What the LinkRow page claims, pinned: where the row and its count lead, and when the count shows. */
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { ITEM, ITEM_UNREAD, LOADING, PLACE, ready, UNAVAILABLE } from './linked.examples'
|
|
6
|
+
import { LinkRow } from './LinkRow'
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup)
|
|
9
|
+
|
|
10
|
+
describe('LinkRow', () => {
|
|
11
|
+
it('opens what it links from the whole row', () => {
|
|
12
|
+
render(<LinkRow link={ready(ITEM)} preview={false} />)
|
|
13
|
+
expect(screen.getByRole('link', { name: ITEM.title }).getAttribute('href')).toBe('#item')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('keeps to one line: the count shows only when something in it is unread', () => {
|
|
17
|
+
const { rerender } = render(<LinkRow link={ready(ITEM)} preview={false} />)
|
|
18
|
+
expect(screen.queryByRole('link', { name: /open the conversation/ })).toBeNull()
|
|
19
|
+
rerender(<LinkRow link={ready(ITEM_UNREAD)} preview={false} />)
|
|
20
|
+
expect(screen.getByRole('link', { name: /unread — open the conversation/ }).getAttribute('href')).toBe('#item-conversation')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('always shows the count where conversations are what it holds', () => {
|
|
24
|
+
render(<LinkRow link={ready(PLACE)} preview={false} />)
|
|
25
|
+
expect(screen.getByRole('link', { name: /conversations — open the conversation/ })).toBeTruthy()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('while it loads, is busy and leads nowhere yet', () => {
|
|
29
|
+
const { container } = render(<LinkRow link={LOADING} />)
|
|
30
|
+
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()
|
|
31
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('says it is not available in one line', () => {
|
|
35
|
+
render(<LinkRow link={UNAVAILABLE} />)
|
|
36
|
+
expect(screen.getByText('Item — not available.')).toBeTruthy()
|
|
37
|
+
})
|
|
38
|
+
})
|
package/src/LinkRow.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A link alone on its line: one 36px row for every kind. Hovering it shows its
|
|
3
|
+
* card; a tap opens it. Loading draws the row in bars; not available says so
|
|
4
|
+
* in one line of the same height.
|
|
5
|
+
*/
|
|
6
|
+
import { Avatar } from './Avatar'
|
|
7
|
+
import { AvatarGroup } from './AvatarGroup'
|
|
8
|
+
import { Card } from './Card'
|
|
9
|
+
import { Link } from './Link'
|
|
10
|
+
import { chosenOf, type LinkedKind, type LinkState } from './linked'
|
|
11
|
+
import { CommentCount, LinkedIcon, LinkKey, ProgressRing, StatusMark } from './LinkParts'
|
|
12
|
+
import { LinkPreview } from './LinkPreview'
|
|
13
|
+
import { SkeletonBar } from './Skeleton'
|
|
14
|
+
|
|
15
|
+
export interface LinkUnavailableProps {
|
|
16
|
+
kind: LinkedKind
|
|
17
|
+
/** What it is, in a word: "Item". Drawn as "Item — not available.". */
|
|
18
|
+
noun: string
|
|
19
|
+
/** Where it may still open, for someone who can: its app and address. */
|
|
20
|
+
openIn?: { app: string; href: string }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Not available: deleted, or not this reader's to see, which read the same.
|
|
25
|
+
* One line at a row's height, whether a row or a card was to draw. The way
|
|
26
|
+
* out stays, since the app it lives in may still open it for you.
|
|
27
|
+
*/
|
|
28
|
+
export function LinkUnavailable({ kind, noun, openIn }: LinkUnavailableProps) {
|
|
29
|
+
return (
|
|
30
|
+
<Card fill="none" unreadable className="flex h-9 items-center gap-2 px-3">
|
|
31
|
+
<LinkedIcon kind={kind} />
|
|
32
|
+
<span className="min-w-0 flex-1 truncate text-body-2 text-text-muted">{noun} — not available.</span>
|
|
33
|
+
{openIn && (
|
|
34
|
+
<span className="shrink-0 text-caption text-text-secondary">
|
|
35
|
+
<Link href={openIn.href} variant="quiet" external>
|
|
36
|
+
Open in {openIn.app} ↗
|
|
37
|
+
</Link>
|
|
38
|
+
</span>
|
|
39
|
+
)}
|
|
40
|
+
</Card>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LinkRowProps {
|
|
45
|
+
link: LinkState
|
|
46
|
+
/** Hovering shows its card. */
|
|
47
|
+
preview?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function LinkRow({ link, preview = true }: LinkRowProps) {
|
|
51
|
+
if (link.state === 'loading') {
|
|
52
|
+
return (
|
|
53
|
+
<div className="animate-skeleton-in" aria-busy>
|
|
54
|
+
<Card fill="inset" aria-hidden className="flex h-9 items-center gap-2 px-3">
|
|
55
|
+
<LinkedIcon kind={link.kind} />
|
|
56
|
+
<SkeletonBar on="inset" className="h-3 w-2/5" />
|
|
57
|
+
<SkeletonBar on="inset" shape="round" className="ml-auto size-4" />
|
|
58
|
+
</Card>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
if (link.state === 'unavailable') return <LinkUnavailable kind={link.kind} noun={link.noun} openIn={link.openIn} />
|
|
63
|
+
const { linked } = link
|
|
64
|
+
const status = chosenOf(linked.status)
|
|
65
|
+
const owner = chosenOf(linked.owner)
|
|
66
|
+
const said = linked.kind === 'message' && linked.author
|
|
67
|
+
const row = (
|
|
68
|
+
<Card fill="inset" hover="hairline" className="relative flex h-9 items-center gap-2 px-3">
|
|
69
|
+
{said ? <Avatar name={said.name} src={said.picture} size={16} /> : <LinkedIcon kind={linked.kind} />}
|
|
70
|
+
{said ? (
|
|
71
|
+
<span className="flex min-w-0 flex-1 items-baseline gap-2 text-body-2">
|
|
72
|
+
<span className="shrink-0 text-body-2-strong text-text-primary">{said.name}</span>
|
|
73
|
+
<span className="contents text-text-secondary">
|
|
74
|
+
<Link href={linked.href} variant="quiet" cover truncate>
|
|
75
|
+
{linked.body ?? linked.title}
|
|
76
|
+
</Link>
|
|
77
|
+
</span>
|
|
78
|
+
</span>
|
|
79
|
+
) : (
|
|
80
|
+
<>
|
|
81
|
+
{linked.key && <LinkKey>{linked.key}</LinkKey>}
|
|
82
|
+
<span className="contents text-body-2-strong text-text-primary">
|
|
83
|
+
<Link href={linked.href} variant="quiet" cover truncate className="min-w-0 flex-1">
|
|
84
|
+
{linked.title}
|
|
85
|
+
</Link>
|
|
86
|
+
</span>
|
|
87
|
+
</>
|
|
88
|
+
)}
|
|
89
|
+
{linked.updated && <span className="shrink-0 text-caption text-text-secondary">{linked.updated}</span>}
|
|
90
|
+
{/* A row keeps to one line: the count shows only when there is something unread in it, except where conversations are what it holds. */}
|
|
91
|
+
{linked.conversation && (linked.conversation.unread || linked.kind === 'topic') && <CommentCount conversation={linked.conversation} noun={linked.conversation.noun} />}
|
|
92
|
+
{/* A ring already says how far along it is. */}
|
|
93
|
+
{status?.colour && !linked.done && <StatusMark status={{ label: status.label, colour: status.colour }} />}
|
|
94
|
+
{linked.done && <ProgressRing done={linked.done.done} total={linked.done.total} />}
|
|
95
|
+
{owner && <Avatar name={owner.label} src={owner.picture} size={16} />}
|
|
96
|
+
{linked.members && <AvatarGroup members={linked.members} size={16} />}
|
|
97
|
+
{linked.authors && <AvatarGroup members={linked.authors} size={16} />}
|
|
98
|
+
</Card>
|
|
99
|
+
)
|
|
100
|
+
return preview ? (
|
|
101
|
+
<LinkPreview linked={linked} block>
|
|
102
|
+
{row}
|
|
103
|
+
</LinkPreview>
|
|
104
|
+
) : (
|
|
105
|
+
row
|
|
106
|
+
)
|
|
107
|
+
}
|
package/src/PartCard.mdx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as PartCardStories from './PartCard.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={PartCardStories} />
|
|
5
|
+
|
|
6
|
+
# PartCard
|
|
7
|
+
|
|
8
|
+
A part of something longer, shared live: its blocks as they are now, under the header of what it is part of.
|
|
9
|
+
|
|
10
|
+
<Canvas of={PartCardStories.Default} />
|
|
11
|
+
|
|
12
|
+
## When
|
|
13
|
+
|
|
14
|
+
- **ready** — the icon, the key and the title of what it is part of, as the link, and the blocks the app draws as `children`.
|
|
15
|
+
- **loading** — at a card's height.
|
|
16
|
+
- **unavailable** — one line: "A part of this item — not available.".
|
|
17
|
+
|
|
18
|
+
## When not
|
|
19
|
+
|
|
20
|
+
- A link to the whole thing → **LinkCard** or **CardUnder**.
|
|
21
|
+
|
|
22
|
+
## How
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { PartCard } from '@estiva-app/ui'
|
|
26
|
+
|
|
27
|
+
<PartCard state="ready" kind={kind} heading={title} partKey={key} href={hrefAtThePart}>
|
|
28
|
+
{blocksDrawnByTheApp}
|
|
29
|
+
</PartCard>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- The blocks are the app's to draw, with its own renderer.
|
|
33
|
+
- There is no ✕: to send without the part, the writer deletes the link.
|
|
34
|
+
|
|
35
|
+
## What it owns
|
|
36
|
+
|
|
37
|
+
| It does | So you never write |
|
|
38
|
+
|---|---|
|
|
39
|
+
| Marks the blocks as quoted from elsewhere | a border of your own |
|
|
40
|
+
|
|
41
|
+
## Props
|
|
42
|
+
|
|
43
|
+
<Controls of={PartCardStories.Default} />
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { PartCard } from './PartCard'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/PartCard',
|
|
6
|
+
component: PartCard,
|
|
7
|
+
parameters: { layout: 'padded' },
|
|
8
|
+
decorators: [(Story) => <div className="max-w-[480px]">{Story()}</div>],
|
|
9
|
+
} satisfies Meta<typeof PartCard>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
/** A part of something longer, under the header of what it is part of. */
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
args: {
|
|
17
|
+
state: 'ready',
|
|
18
|
+
kind: 'issue',
|
|
19
|
+
heading: 'Item one',
|
|
20
|
+
partKey: 'ABC-2',
|
|
21
|
+
href: '#part',
|
|
22
|
+
children: (
|
|
23
|
+
<>
|
|
24
|
+
<p className="text-body-2-strong text-text-primary">A heading in the part</p>
|
|
25
|
+
<p className="text-body-2 text-text-secondary">The blocks of the part, as they are now. They change when the text they came from changes.</p>
|
|
26
|
+
</>
|
|
27
|
+
),
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Still being looked up. */
|
|
32
|
+
export const Loading: Story = { args: { state: 'loading', kind: 'issue' } }
|
|
33
|
+
|
|
34
|
+
/** The part, or what it is part of, is gone or not this reader's to see. */
|
|
35
|
+
export const NotAvailable: Story = { args: { state: 'unavailable', kind: 'issue', noun: 'Item', openIn: { app: 'App one', href: '#elsewhere' } } }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/** What the PartCard page claims, pinned: the header opens what it is part of, the blocks draw, and the states say so. */
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { PartCard } from './PartCard'
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup)
|
|
8
|
+
|
|
9
|
+
describe('PartCard', () => {
|
|
10
|
+
it('opens what it is part of from its header, and draws the blocks it is given', () => {
|
|
11
|
+
render(
|
|
12
|
+
<PartCard kind="issue" heading="Item one" partKey="ABC-2" href="#part">
|
|
13
|
+
<p>The blocks</p>
|
|
14
|
+
</PartCard>,
|
|
15
|
+
)
|
|
16
|
+
expect(screen.getByRole('link', { name: /ABC-2.*Item one/ }).getAttribute('href')).toBe('#part')
|
|
17
|
+
expect(screen.getByText('The blocks')).toBeTruthy()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('says a part is not available in words', () => {
|
|
21
|
+
render(<PartCard state="unavailable" kind="issue" noun="Item" />)
|
|
22
|
+
expect(screen.getByText('A part of this item — not available.')).toBeTruthy()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('while it loads, is busy and shows nothing of it', () => {
|
|
26
|
+
const { container } = render(<PartCard state="loading" kind="issue" />)
|
|
27
|
+
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()
|
|
28
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
})
|