@estiva-app/ui 0.38.0 → 0.40.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/AppShell.d.ts +5 -2
- package/dist/AppShell.d.ts.map +1 -1
- package/dist/RichText.d.ts +78 -0
- package/dist/RichText.d.ts.map +1 -0
- package/dist/Sidebar.d.ts +7 -1
- package/dist/Sidebar.d.ts.map +1 -1
- package/dist/TopBar.d.ts +5 -2
- package/dist/TopBar.d.ts.map +1 -1
- 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/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/index.js +11 -1
- package/dist/gates/index.js.map +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +688 -542
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +97 -8
- package/src/AppShell.mdx +11 -3
- package/src/AppShell.stories.tsx +19 -0
- package/src/AppShell.test.tsx +20 -0
- package/src/AppShell.tsx +43 -24
- package/src/RichText.mdx +79 -0
- package/src/RichText.stories.tsx +91 -0
- package/src/RichText.test.tsx +149 -0
- package/src/RichText.tsx +294 -0
- package/src/Sidebar.tsx +10 -2
- package/src/TopBar.mdx +6 -4
- package/src/TopBar.tsx +6 -3
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/gates/app-checks.ts +7 -0
- package/src/index.ts +1 -0
- package/src/registry/registry.test.ts +5 -5
- package/stories/Choosing.mdx +1 -0
- package/tailwind-preset.js +1 -0
- package/tokens.css +15 -0
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
"kind": "package",
|
|
5
5
|
"repo": "estiva-ui",
|
|
6
6
|
"package": "@estiva-app/ui",
|
|
7
|
-
"packageVersion": "0.
|
|
8
|
-
"exports":
|
|
9
|
-
"typeExports":
|
|
7
|
+
"packageVersion": "0.40.0",
|
|
8
|
+
"exports": 90,
|
|
9
|
+
"typeExports": 101,
|
|
10
10
|
"files": null
|
|
11
11
|
},
|
|
12
12
|
"storybook": {
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
"kind": "component",
|
|
22
22
|
"importPath": "@estiva-app/ui",
|
|
23
23
|
"sourceFile": "src/AppShell.tsx",
|
|
24
|
-
"purpose": "The frame of an app, in
|
|
24
|
+
"purpose": "The frame of an app, in three manners: `solid` — the structured frame, a solid TopBar over a Sidebar and the content beside it — `inset` — the same frame the other way round, the TopBar and the Sidebar standing on the ground and the content a lighter card beside them — and `floating` — the TopBar floating over the top edge, a Rail standing on the background, and the content living in the rounded card.",
|
|
25
25
|
"purposeFrom": "page",
|
|
26
26
|
"props": [
|
|
27
27
|
{
|
|
28
28
|
"name": "variant",
|
|
29
|
-
"takes": "solid | floating",
|
|
29
|
+
"takes": "solid | floating | inset",
|
|
30
30
|
"required": false,
|
|
31
31
|
"note": null
|
|
32
32
|
},
|
|
@@ -78,7 +78,8 @@
|
|
|
78
78
|
"prop": "variant",
|
|
79
79
|
"values": [
|
|
80
80
|
"solid",
|
|
81
|
-
"floating"
|
|
81
|
+
"floating",
|
|
82
|
+
"inset"
|
|
82
83
|
]
|
|
83
84
|
}
|
|
84
85
|
],
|
|
@@ -4078,6 +4079,93 @@
|
|
|
4078
4079
|
"app": null,
|
|
4079
4080
|
"looks": []
|
|
4080
4081
|
},
|
|
4082
|
+
{
|
|
4083
|
+
"name": "RichText",
|
|
4084
|
+
"repo": "estiva-ui",
|
|
4085
|
+
"kind": "component",
|
|
4086
|
+
"importPath": "@estiva-app/ui",
|
|
4087
|
+
"sourceFile": "src/RichText.tsx",
|
|
4088
|
+
"purpose": "Formatted text, drawn from a parsed tree: headings, paragraphs, both lists, a quote, code, a divider, a table and links, in one look for every app.",
|
|
4089
|
+
"purposeFrom": "page",
|
|
4090
|
+
"props": [
|
|
4091
|
+
{
|
|
4092
|
+
"name": "blocks",
|
|
4093
|
+
"takes": "readonly RichTextBlock[]",
|
|
4094
|
+
"required": true,
|
|
4095
|
+
"note": "The tree, already parsed."
|
|
4096
|
+
},
|
|
4097
|
+
{
|
|
4098
|
+
"name": "size",
|
|
4099
|
+
"takes": "default | small",
|
|
4100
|
+
"required": false,
|
|
4101
|
+
"note": "Default `default`, the size of text you read."
|
|
4102
|
+
},
|
|
4103
|
+
{
|
|
4104
|
+
"name": "renderText",
|
|
4105
|
+
"takes": "a handler",
|
|
4106
|
+
"required": false,
|
|
4107
|
+
"note": "Draws plain text: what is left of a run once its links are drawn."
|
|
4108
|
+
},
|
|
4109
|
+
{
|
|
4110
|
+
"name": "renderReference",
|
|
4111
|
+
"takes": "a handler",
|
|
4112
|
+
"required": false,
|
|
4113
|
+
"note": "Draws a run that stands for an address. By default its text."
|
|
4114
|
+
},
|
|
4115
|
+
{
|
|
4116
|
+
"name": "renderBlock",
|
|
4117
|
+
"takes": "a handler",
|
|
4118
|
+
"required": false,
|
|
4119
|
+
"note": "Draws a block this part has no design for (an attachment, a widget)."
|
|
4120
|
+
}
|
|
4121
|
+
],
|
|
4122
|
+
"variants": [
|
|
4123
|
+
{
|
|
4124
|
+
"prop": "size",
|
|
4125
|
+
"values": [
|
|
4126
|
+
"default",
|
|
4127
|
+
"small"
|
|
4128
|
+
]
|
|
4129
|
+
}
|
|
4130
|
+
],
|
|
4131
|
+
"ownsBehaviours": [],
|
|
4132
|
+
"status": "stable",
|
|
4133
|
+
"migrationStage": null,
|
|
4134
|
+
"docsId": "components-richtext--docs",
|
|
4135
|
+
"storyId": "components-richtext--every-block",
|
|
4136
|
+
"docPage": "src/RichText.mdx",
|
|
4137
|
+
"app": null,
|
|
4138
|
+
"looks": [
|
|
4139
|
+
"[&>:is(h1,h2,h3)]:mb-1 [&>:is(h1,h2,h3)]:mt-2 [&>:is(h1,h2,h3):first-child]:mt-0 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2)]:text-text-primary",
|
|
4140
|
+
"[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-muted [&_ol]:list-decimal [&_ul]:list-disc",
|
|
4141
|
+
"[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border-strong [&_blockquote]:pl-2.5",
|
|
4142
|
+
"[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_code]:text-text-primary",
|
|
4143
|
+
"[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:p-2 [&_pre]:font-mono [&_pre]:text-[0.9em] [&_pre]:text-text-primary",
|
|
4144
|
+
"[&_:is(th,td)>p]:m-0 [&_:is(th,td)]:border [&_:is(th,td)]:border-border-default [&_:is(th,td)]:px-2 [&_:is(th,td)]:py-1 [&_:is(th,td)]:text-left [&_:is(th,td)]:align-top [&_table]:w-full [&_table]:border-collapse [&_th]:bg-bg-code [&_th]:font-medium [&_th]:text-text-primary",
|
|
4145
|
+
"[&_em]:italic [&_strong]:font-semibold [&_u]:underline [&_u]:underline-offset-2",
|
|
4146
|
+
"text-body-2 [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4",
|
|
4147
|
+
"text-caption leading-snug [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold"
|
|
4148
|
+
]
|
|
4149
|
+
},
|
|
4150
|
+
{
|
|
4151
|
+
"name": "richTextClassName",
|
|
4152
|
+
"repo": "estiva-ui",
|
|
4153
|
+
"kind": "helper",
|
|
4154
|
+
"importPath": "@estiva-app/ui",
|
|
4155
|
+
"sourceFile": "src/RichText.tsx",
|
|
4156
|
+
"purpose": "RichText's look, for a box you draw yourself: an editor's, so that what is being written looks the same as what is read.",
|
|
4157
|
+
"purposeFrom": "comment",
|
|
4158
|
+
"props": [],
|
|
4159
|
+
"variants": [],
|
|
4160
|
+
"ownsBehaviours": [],
|
|
4161
|
+
"status": "stable",
|
|
4162
|
+
"migrationStage": null,
|
|
4163
|
+
"docsId": "components-richtext--docs",
|
|
4164
|
+
"storyId": "components-richtext--every-block",
|
|
4165
|
+
"docPage": null,
|
|
4166
|
+
"app": null,
|
|
4167
|
+
"looks": []
|
|
4168
|
+
},
|
|
4081
4169
|
{
|
|
4082
4170
|
"name": "ScrollArea",
|
|
4083
4171
|
"repo": "estiva-ui",
|
|
@@ -5412,7 +5500,7 @@
|
|
|
5412
5500
|
"props": [
|
|
5413
5501
|
{
|
|
5414
5502
|
"name": "variant",
|
|
5415
|
-
"takes": "solid | floating",
|
|
5503
|
+
"takes": "solid | floating | inset",
|
|
5416
5504
|
"required": false,
|
|
5417
5505
|
"note": null
|
|
5418
5506
|
},
|
|
@@ -5452,7 +5540,8 @@
|
|
|
5452
5540
|
"prop": "variant",
|
|
5453
5541
|
"values": [
|
|
5454
5542
|
"solid",
|
|
5455
|
-
"floating"
|
|
5543
|
+
"floating",
|
|
5544
|
+
"inset"
|
|
5456
5545
|
]
|
|
5457
5546
|
}
|
|
5458
5547
|
],
|
package/src/AppShell.mdx
CHANGED
|
@@ -5,14 +5,18 @@ import * as AppShellStories from './AppShell.stories'
|
|
|
5
5
|
|
|
6
6
|
# AppShell
|
|
7
7
|
|
|
8
|
-
The frame of an app, in
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
The frame of an app, in three manners: **`solid`** — the structured
|
|
9
|
+
frame, a solid TopBar over a Sidebar and the content beside it —
|
|
10
|
+
**`inset`** — the same frame the other way round, the TopBar and the
|
|
11
|
+
Sidebar standing on the ground and the content a lighter card beside
|
|
12
|
+
them — and **`floating`** — the TopBar floating over the top edge, a Rail
|
|
11
13
|
standing on the background, and the content living in the rounded card.
|
|
12
14
|
Layout only: no data, no routes.
|
|
13
15
|
|
|
14
16
|
<Canvas of={AppShellStories.Solid} />
|
|
15
17
|
|
|
18
|
+
<Canvas of={AppShellStories.Inset} />
|
|
19
|
+
|
|
16
20
|
<Canvas of={AppShellStories.Floating} />
|
|
17
21
|
|
|
18
22
|
## When
|
|
@@ -20,6 +24,10 @@ Layout only: no data, no routes.
|
|
|
20
24
|
- **`solid` + Sidebar** — a place-by-place app: labelled entries, counts,
|
|
21
25
|
groups. Sidebar and main scroll independently, so a long list never
|
|
22
26
|
scrolls the navigation away.
|
|
27
|
+
- **`inset` + Sidebar** — the same place-by-place app, when its pages
|
|
28
|
+
read as one sheet on a desk: the content is a card 16px from the right
|
|
29
|
+
and bottom, with 8px corners, and the Sidebar drops its line and surface
|
|
30
|
+
by itself. Any theme: the ground and the card are its base and surface.
|
|
23
31
|
- **`floating` + Rail** — an app with a handful of top-level places and
|
|
24
32
|
its content in one card; the `menu` button in the bar collapses the
|
|
25
33
|
rail (the collapsing stays the app's).
|
package/src/AppShell.stories.tsx
CHANGED
|
@@ -95,6 +95,25 @@ export const SolidWithBanner: Story = {
|
|
|
95
95
|
),
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** The structured frame the other way round: the bar and the sidebar on the ground, the content a lighter card beside them. */
|
|
99
|
+
export const Inset: Story = {
|
|
100
|
+
args: { variant: 'inset' },
|
|
101
|
+
render: (args) => (
|
|
102
|
+
<AppShell {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} identity={identity} nav={sidebar}>
|
|
103
|
+
<EmptyState message="Nothing here yet." />
|
|
104
|
+
</AppShell>
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const InsetWithBanner: Story = {
|
|
109
|
+
args: { variant: 'inset' },
|
|
110
|
+
render: (args) => (
|
|
111
|
+
<AppShell {...args} logo="Estiva" identity={identity} nav={sidebar} banner={<Banner tone="ok">Public key copied.</Banner>}>
|
|
112
|
+
<EmptyState message="Nothing here yet." />
|
|
113
|
+
</AppShell>
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
|
|
98
117
|
/** The floating frame: the bar floats over the top edge, the rail stands on the background, and the content lives in the rounded card. */
|
|
99
118
|
export const Floating: Story = {
|
|
100
119
|
render: (args) => (
|
package/src/AppShell.test.tsx
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
10
10
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
11
11
|
import { AppShell } from './AppShell'
|
|
12
|
+
import { Sidebar } from './Sidebar'
|
|
12
13
|
|
|
13
14
|
afterEach(cleanup)
|
|
14
15
|
|
|
@@ -26,6 +27,25 @@ describe('AppShell', () => {
|
|
|
26
27
|
expect(main.className).not.toContain('overflow-y-auto')
|
|
27
28
|
})
|
|
28
29
|
|
|
30
|
+
it('stands the sidebar on the ground and makes the content a panel in the inset manner, and only there', () => {
|
|
31
|
+
const frame = (variant: 'solid' | 'inset') =>
|
|
32
|
+
render(
|
|
33
|
+
<AppShell variant={variant} nav={<Sidebar>Rows</Sidebar>}>
|
|
34
|
+
<p>Page</p>
|
|
35
|
+
</AppShell>,
|
|
36
|
+
)
|
|
37
|
+
const { unmount } = frame('solid')
|
|
38
|
+
expect(screen.getByRole('navigation').className).toContain('border-r')
|
|
39
|
+
expect(screen.getByRole('banner').className).toContain('border-b')
|
|
40
|
+
unmount()
|
|
41
|
+
frame('inset')
|
|
42
|
+
expect(screen.getByRole('navigation').className).not.toMatch(/border-r|bg-bg-surface/)
|
|
43
|
+
expect(screen.getByRole('banner').className).not.toMatch(/border-b|bg-bg-surface/)
|
|
44
|
+
const panel = screen.getByRole('main').closest('.rounded-lg')
|
|
45
|
+
expect(panel?.className).toContain('bg-bg-surface')
|
|
46
|
+
expect(panel?.className).toContain('mr-4')
|
|
47
|
+
})
|
|
48
|
+
|
|
29
49
|
it('leaves the floating manner’s card as it was', () => {
|
|
30
50
|
render(
|
|
31
51
|
<AppShell variant="floating" nav={<nav>Nav</nav>}>
|
package/src/AppShell.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
|
+
import { Card } from './Card'
|
|
3
4
|
import { ScrollArea } from './ScrollArea'
|
|
5
|
+
import { SidebarOnGround } from './Sidebar'
|
|
4
6
|
import { TopBar } from './TopBar'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
* The frame of an app, in the
|
|
9
|
+
* The frame of an app, in the manners that pair with the TopBar's
|
|
8
10
|
* (Katerina, 2026-09-02: the two shells go together — the solid bar with
|
|
9
11
|
* the sidebar, the floating bar with the rail):
|
|
10
12
|
*
|
|
@@ -15,6 +17,9 @@ import { TopBar } from './TopBar'
|
|
|
15
17
|
* edge, the rail stands on the app background, and the content lives
|
|
16
18
|
* in the rounded card beside it (2026-09-02, the floating app's frame
|
|
17
19
|
* geometry; its collapse animation and launcher stay in the app).
|
|
20
|
+
* - `inset` — the structured frame, the other way round: the TopBar and
|
|
21
|
+
* the Sidebar stand on the ground, and the content is a lighter card
|
|
22
|
+
* beside them (Katerina, 28 September: Linear's layout, in any theme).
|
|
18
23
|
*
|
|
19
24
|
* The banner belongs to the content area (her ruling, 2026-09-02): it
|
|
20
25
|
* renders at the top of the main column — inside the card, in the
|
|
@@ -24,7 +29,7 @@ import { TopBar } from './TopBar'
|
|
|
24
29
|
* has no data and no routes.
|
|
25
30
|
*/
|
|
26
31
|
export interface AppShellProps {
|
|
27
|
-
variant?: 'solid' | 'floating'
|
|
32
|
+
variant?: 'solid' | 'floating' | 'inset'
|
|
28
33
|
/** The top bar's menu button, in an app whose navigation collapses. */
|
|
29
34
|
menu?: ReactNode
|
|
30
35
|
/** The top bar's left: the app's logo mark, or its name as text. */
|
|
@@ -42,6 +47,7 @@ export interface AppShellProps {
|
|
|
42
47
|
|
|
43
48
|
export function AppShell({ variant = 'solid', menu, logo, search, identity, banner, nav, children }: AppShellProps) {
|
|
44
49
|
const bar = <TopBar variant={variant} menu={menu} logo={logo} search={search} right={identity} />
|
|
50
|
+
const inset = variant === 'inset'
|
|
45
51
|
|
|
46
52
|
if (variant === 'floating') {
|
|
47
53
|
return (
|
|
@@ -66,6 +72,30 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
66
72
|
)
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
const column = (
|
|
76
|
+
<>
|
|
77
|
+
{banner}
|
|
78
|
+
{/* The frame owns the page's scrollbar (Katerina, 2026-09-09: the
|
|
79
|
+
Issues page still had a native bar — D40 said everywhere, and the
|
|
80
|
+
one place every page passes through had been left out). A
|
|
81
|
+
`ScrollArea` here means no page can forget it. The content box is
|
|
82
|
+
at least the viewport's height and grows with a tall page, so Base
|
|
83
|
+
UI sees its size change (the morning's phantom bar was a content
|
|
84
|
+
box it could not watch); `main` fills it as a flex column. The
|
|
85
|
+
page contract (the AppShell page says it): a page is a flex
|
|
86
|
+
child of `main` — `flex-1` to fill and scroll here; `flex-1
|
|
87
|
+
min-h-0 [contain:size]` to take exactly the frame's height and
|
|
88
|
+
scroll inside itself, as Ship's detail grids do — and the outer
|
|
89
|
+
region, with nothing to scroll, passes the wheel through. Never
|
|
90
|
+
`h-full`: a box that is *at least* the viewport's height gives a
|
|
91
|
+
percentage nothing to resolve against — measured, all four Ship
|
|
92
|
+
pages, 2026-09-09. */}
|
|
93
|
+
<ScrollArea className="min-h-0 flex-1" contentClassName="flex min-h-full flex-col">
|
|
94
|
+
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
|
|
95
|
+
</ScrollArea>
|
|
96
|
+
</>
|
|
97
|
+
)
|
|
98
|
+
|
|
69
99
|
return (
|
|
70
100
|
/* `relative overflow-hidden` is the seal the floating manner already has
|
|
71
101
|
(and the ScrollArea's own root is `relative`, so an absolutely placed
|
|
@@ -79,28 +109,17 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
79
109
|
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-bg-base text-text-primary">
|
|
80
110
|
{bar}
|
|
81
111
|
<div className="flex min-h-0 flex-1">
|
|
82
|
-
{nav}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
child of `main` — `flex-1` to fill and scroll here; `flex-1
|
|
94
|
-
min-h-0 [contain:size]` to take exactly the frame's height and
|
|
95
|
-
scroll inside itself, as Ship's detail grids do — and the outer
|
|
96
|
-
region, with nothing to scroll, passes the wheel through. Never
|
|
97
|
-
`h-full`: a box that is *at least* the viewport's height gives a
|
|
98
|
-
percentage nothing to resolve against — measured, all four Ship
|
|
99
|
-
pages, 2026-09-09. */}
|
|
100
|
-
<ScrollArea className="min-h-0 flex-1" contentClassName="flex min-h-full flex-col">
|
|
101
|
-
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
|
|
102
|
-
</ScrollArea>
|
|
103
|
-
</div>
|
|
112
|
+
<SidebarOnGround.Provider value={inset}>{nav}</SidebarOnGround.Provider>
|
|
113
|
+
{/* `inset`: the content is a card on the ground, the Card's own surface look,
|
|
114
|
+
16px from the right and bottom as the floating card is, 8px corners (Katerina,
|
|
115
|
+
28 September). */}
|
|
116
|
+
{inset ? (
|
|
117
|
+
<Card fill="surface" clip className="mb-4 mr-4 flex min-h-0 min-w-0 flex-1 flex-col">
|
|
118
|
+
{column}
|
|
119
|
+
</Card>
|
|
120
|
+
) : (
|
|
121
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">{column}</div>
|
|
122
|
+
)}
|
|
104
123
|
</div>
|
|
105
124
|
</div>
|
|
106
125
|
)
|
package/src/RichText.mdx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as RichTextStories from './RichText.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={RichTextStories} />
|
|
5
|
+
|
|
6
|
+
# RichText
|
|
7
|
+
|
|
8
|
+
Formatted text, drawn from a parsed tree: headings, paragraphs, both lists, a
|
|
9
|
+
quote, code, a divider, a table and links, in one look for every app.
|
|
10
|
+
|
|
11
|
+
<Canvas of={RichTextStories.EveryBlock} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **`default`** — text someone reads: a post, a reply, a description.
|
|
16
|
+
- **`small`** — the same text one step down, with every heading, list, quote
|
|
17
|
+
and table kept: an item shown inside a card.
|
|
18
|
+
- `renderReference` — a run that stands for an address: the app draws what it
|
|
19
|
+
points at, a person or an item.
|
|
20
|
+
- `renderText` — words the app recognises in plain text, drawn as chips.
|
|
21
|
+
- `renderBlock` — a block this part has no design for, such as an attachment.
|
|
22
|
+
|
|
23
|
+
## When not
|
|
24
|
+
|
|
25
|
+
- One word standing for a person or a thing, inside your own sentence →
|
|
26
|
+
**InlineChip**.
|
|
27
|
+
- One link inside your own sentence → **Link**.
|
|
28
|
+
- A value set out as a label and its value → **Property**.
|
|
29
|
+
|
|
30
|
+
## How
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { InlineChip, RichText } from '@estiva-app/ui'
|
|
34
|
+
|
|
35
|
+
// toRenderTree comes from @estiva-app/protocol
|
|
36
|
+
<RichText
|
|
37
|
+
blocks={toRenderTree(value, format)}
|
|
38
|
+
renderReference={(reference) => <InlineChip tone="person">{nameOf(reference)}</InlineChip>}
|
|
39
|
+
/>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- **It takes the tree, not the text.** Parse first; the format comes from the
|
|
43
|
+
item's tag, never from its body. The protocol's `RenderBlock` fits
|
|
44
|
+
`RichTextBlock`. To draw a block somewhere else (below the text, as a card),
|
|
45
|
+
leave it out of `blocks`.
|
|
46
|
+
- **The colour is set here**: the text is `text-secondary`, and headings
|
|
47
|
+
of level 1 and 2 are `text-primary`. Do not wrap it to recolour it.
|
|
48
|
+
- **Links are found here**, in every run that is not code: `[words](address)`
|
|
49
|
+
and a bare `https://` address. Both open in a new tab, and a click on one
|
|
50
|
+
is not also a click on what the text sits in, such as a card. `renderText`
|
|
51
|
+
gets only the text between links, so an address is never read as a name.
|
|
52
|
+
- **Nothing is set as HTML.** Every string reaches the page as text; a `<b>`
|
|
53
|
+
typed by an author shows as `<b>`.
|
|
54
|
+
- A block of a kind it does not know shows its words and its children. It is
|
|
55
|
+
never dropped.
|
|
56
|
+
- Code, in a sentence or as a block, is 0.9 of the text around it, so it
|
|
57
|
+
shrinks with `small`. A long line in a block wraps; nothing scrolls
|
|
58
|
+
sideways.
|
|
59
|
+
- Each block carries its `id` as `data-block-id`, for an app that points at
|
|
60
|
+
one block.
|
|
61
|
+
- **An editor wears the same look.** Put `richTextClassName()` (or
|
|
62
|
+
`richTextClassName('small')`) on the editor's root, so what is being written
|
|
63
|
+
looks exactly like what is read. It styles the elements inside the box,
|
|
64
|
+
including the paragraph an editor puts inside a list item, a quote and a
|
|
65
|
+
table cell.
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
import { richTextClassName } from '@estiva-app/ui'
|
|
69
|
+
|
|
70
|
+
const editorAttributes = { class: richTextClassName('default', 'outline-none') }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## What it owns
|
|
74
|
+
|
|
75
|
+
Nothing. It only draws. Clicks and keys are yours.
|
|
76
|
+
|
|
77
|
+
## Props
|
|
78
|
+
|
|
79
|
+
<Controls of={RichTextStories.EveryBlock} />
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
3
|
+
import { RichText, type RichTextBlock, type RichTextRun } from './RichText'
|
|
4
|
+
import { InlineChip } from './InlineChip'
|
|
5
|
+
import { Card } from './Card'
|
|
6
|
+
|
|
7
|
+
const t = (text: string, ...marks: string[]): RichTextRun => ({ text, marks })
|
|
8
|
+
const p = (...inline: RichTextRun[]): RichTextBlock => ({ type: 'paragraph', inline })
|
|
9
|
+
const items = (...texts: string[]): RichTextBlock[] => texts.map((text) => ({ type: 'listItem', inline: [t(text)] }))
|
|
10
|
+
const cell = (type: 'tableHeader' | 'tableCell', text: string): RichTextBlock => ({ type: 'unknown', typeName: type, children: [p(t(text))] })
|
|
11
|
+
const row = (type: 'tableHeader' | 'tableCell', ...texts: string[]): RichTextBlock => ({ type: 'unknown', typeName: 'tableRow', children: texts.map((x) => cell(type, x)) })
|
|
12
|
+
|
|
13
|
+
/** Every block the part draws, in the order a reader meets them. */
|
|
14
|
+
const EVERY_BLOCK: RichTextBlock[] = [
|
|
15
|
+
{ type: 'heading', level: 1, inline: [t('A heading')] },
|
|
16
|
+
{ type: 'heading', level: 2, inline: [t('A smaller heading')] },
|
|
17
|
+
{ type: 'heading', level: 3, inline: [t('A third level, in the text colour')] },
|
|
18
|
+
p(t('Text can be '), t('bold', 'bold'), t(', '), t('italic', 'italic'), t(' or '), t('underlined', 'underline'), t(', and hold '), t('code', 'code'), t(' in a sentence.\nA line break stays a line break.')),
|
|
19
|
+
{ type: 'bulletList', children: items('An item', 'Another item') },
|
|
20
|
+
{ type: 'orderedList', children: items('The first step', 'The second step') },
|
|
21
|
+
{ type: 'blockquote', inline: [t('A quote, set in from a line.')] },
|
|
22
|
+
{ type: 'codeBlock', language: 'ts', inline: [t('const item = 1 // a long line of code folds onto the next line instead of scrolling sideways')] },
|
|
23
|
+
{ type: 'horizontalRule' },
|
|
24
|
+
{
|
|
25
|
+
type: 'table',
|
|
26
|
+
children: [row('tableHeader', 'Label', 'Value'), row('tableCell', 'Item one', 'One'), row('tableCell', 'Item two', 'Two')],
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const meta = {
|
|
31
|
+
title: 'Components/RichText',
|
|
32
|
+
component: RichText,
|
|
33
|
+
args: { blocks: EVERY_BLOCK, size: 'default' },
|
|
34
|
+
argTypes: { size: { control: 'inline-radio', options: ['default', 'small'] } },
|
|
35
|
+
decorators: [(Story) => <div className="w-[480px]"><Story /></div>],
|
|
36
|
+
} satisfies Meta<typeof RichText>
|
|
37
|
+
|
|
38
|
+
export default meta
|
|
39
|
+
type Story = StoryObj<typeof meta>
|
|
40
|
+
|
|
41
|
+
/** Every block: headings, text and its marks, both lists, a quote, code, a divider and a table. */
|
|
42
|
+
export const EveryBlock: Story = {}
|
|
43
|
+
|
|
44
|
+
/** One step down, with every heading, list, quote and table kept: an item shown inside a card. */
|
|
45
|
+
export const Small: Story = {
|
|
46
|
+
args: { size: 'small' },
|
|
47
|
+
render: (args) => (
|
|
48
|
+
<Card className="p-3">
|
|
49
|
+
<RichText {...args} />
|
|
50
|
+
</Card>
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A link written with its own words, and a bare address, which may break anywhere. Code is never read for links. */
|
|
55
|
+
export const Links: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
blocks: [
|
|
58
|
+
p(t('A link with its own words: [the guide](https://example.com/guide). A bare address: https://example.com/a/long/address/that/breaks/where/it/must.')),
|
|
59
|
+
p(t('Inside code it stays text: '), t('[not a link](https://example.com)', 'code')),
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What an app draws itself: a run that stands for an address, and words it recognises in plain text. */
|
|
65
|
+
export const WithTheAppsOwnParts: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
blocks: [
|
|
68
|
+
p(
|
|
69
|
+
t('Assigned to '),
|
|
70
|
+
{ text: 'nostr:npub1example', marks: [], reference: 'nostr:npub1example' },
|
|
71
|
+
t(', see [[Item one]] for the rest.'),
|
|
72
|
+
),
|
|
73
|
+
],
|
|
74
|
+
renderReference: () => <InlineChip tone="person">Name</InlineChip>,
|
|
75
|
+
renderText: (text) =>
|
|
76
|
+
text.split(/(\[\[[^\]]+\]\])/).map((part, i) =>
|
|
77
|
+
part.startsWith('[[') ? (
|
|
78
|
+
<InlineChip key={i} icon={<IconSquareRounded size={16} stroke={1.5} />}>{part.slice(2, -2)}</InlineChip>
|
|
79
|
+
) : (
|
|
80
|
+
part
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A block this part has no design for, from a later version: its words are drawn, never dropped. */
|
|
87
|
+
export const UnknownBlock: Story = {
|
|
88
|
+
args: {
|
|
89
|
+
blocks: [p(t('The block below is of a kind this part does not know.')), { type: 'unknown', typeName: 'later', inline: [t('Its words are still here.')] }],
|
|
90
|
+
},
|
|
91
|
+
}
|