@estiva-app/ui 0.39.0 → 0.41.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/Chip.d.ts +10 -2
- package/dist/Chip.d.ts.map +1 -1
- package/dist/Link.d.ts +15 -1
- package/dist/Link.d.ts.map +1 -1
- package/dist/RichText.d.ts +78 -0
- package/dist/RichText.d.ts.map +1 -0
- package/dist/eslint/index.js +2 -1
- 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 +25 -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 +365 -220
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +117 -7
- package/src/Chip.mdx +13 -2
- package/src/Chip.stories.tsx +2 -0
- package/src/Chip.test.tsx +36 -0
- package/src/Chip.tsx +17 -5
- package/src/Link.mdx +32 -0
- package/src/Link.stories.tsx +49 -0
- package/src/Link.test.tsx +15 -0
- package/src/Link.tsx +23 -2
- 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/eslint/index.test.ts +1 -1
- package/src/eslint/no-restyled-part.test.ts +5 -5
- package/src/eslint/no-restyled-part.ts +2 -1
- package/src/gates/app-checks.ts +21 -0
- package/src/gates/create-app.test.ts +7 -2
- 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.41.0",
|
|
8
|
+
"exports": 90,
|
|
9
|
+
"typeExports": 101,
|
|
10
10
|
"files": null
|
|
11
11
|
},
|
|
12
12
|
"storybook": {
|
|
@@ -792,6 +792,18 @@
|
|
|
792
792
|
"required": false,
|
|
793
793
|
"note": null
|
|
794
794
|
},
|
|
795
|
+
{
|
|
796
|
+
"name": "href",
|
|
797
|
+
"takes": "text",
|
|
798
|
+
"required": false,
|
|
799
|
+
"note": "The chip is a link: a real anchor with the chip's own round shape, so the keyboard ring is round too."
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
"name": "onClick",
|
|
803
|
+
"takes": "MouseEventHandler<HTMLAnchorElement>",
|
|
804
|
+
"required": false,
|
|
805
|
+
"note": "With `href`: a router app takes the click here, like `Link`."
|
|
806
|
+
},
|
|
795
807
|
{
|
|
796
808
|
"name": "className",
|
|
797
809
|
"takes": "text",
|
|
@@ -825,13 +837,13 @@
|
|
|
825
837
|
"bg-warning-muted text-warning-default signal:border signal:border-warning-outline signal:shadow-glow-warning",
|
|
826
838
|
"bg-success-muted text-success-default signal:border signal:border-success-outline",
|
|
827
839
|
"bg-error-muted text-error-default signal:border signal:border-error-outline",
|
|
840
|
+
"overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums",
|
|
841
|
+
"overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide",
|
|
828
842
|
"rounded-full bg-accent-muted text-accent-primary signal:border signal:border-accent-outline",
|
|
829
843
|
"rounded-full bg-info-muted text-info-default signal:border signal:border-info-outline",
|
|
830
844
|
"rounded-full bg-warning-muted text-warning-default signal:border signal:border-warning-outline signal:shadow-glow-warning",
|
|
831
845
|
"rounded-full bg-success-muted text-success-default signal:border signal:border-success-outline",
|
|
832
|
-
"rounded-full bg-error-muted text-error-default signal:border signal:border-error-outline"
|
|
833
|
-
"overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums",
|
|
834
|
-
"overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide"
|
|
846
|
+
"rounded-full bg-error-muted text-error-default signal:border signal:border-error-outline"
|
|
835
847
|
]
|
|
836
848
|
},
|
|
837
849
|
{
|
|
@@ -2749,6 +2761,12 @@
|
|
|
2749
2761
|
"required": false,
|
|
2750
2762
|
"note": "One line, cut with an ellipsis when it does not fit — a title that is a link."
|
|
2751
2763
|
},
|
|
2764
|
+
{
|
|
2765
|
+
"name": "cover",
|
|
2766
|
+
"takes": "true/false",
|
|
2767
|
+
"required": false,
|
|
2768
|
+
"note": "The link covers the row it sits in: a click anywhere on the row opens it, and the keyboard ring goes round the whole row."
|
|
2769
|
+
},
|
|
2752
2770
|
{
|
|
2753
2771
|
"name": "children",
|
|
2754
2772
|
"takes": "anything",
|
|
@@ -2785,8 +2803,13 @@
|
|
|
2785
2803
|
"app": null,
|
|
2786
2804
|
"looks": [
|
|
2787
2805
|
"text-info-default underline underline-offset-2 hover:opacity-80",
|
|
2806
|
+
"text-info-default underline underline-offset-2 hover:opacity-80 after:rounded-md focus-visible:outline-none",
|
|
2788
2807
|
"text-info-default underline underline-offset-2 hover:opacity-80 truncate",
|
|
2789
|
-
"underline underline-offset-2 hover:
|
|
2808
|
+
"text-info-default underline underline-offset-2 hover:opacity-80 truncate after:rounded-md focus-visible:outline-none",
|
|
2809
|
+
"hover:underline truncate after:rounded-md focus-visible:outline-none",
|
|
2810
|
+
"underline underline-offset-2 hover:text-text-primary after:rounded-md focus-visible:outline-none",
|
|
2811
|
+
"underline underline-offset-2 hover:text-text-primary truncate",
|
|
2812
|
+
"underline underline-offset-2 hover:text-text-primary truncate after:rounded-md focus-visible:outline-none"
|
|
2790
2813
|
]
|
|
2791
2814
|
},
|
|
2792
2815
|
{
|
|
@@ -4079,6 +4102,93 @@
|
|
|
4079
4102
|
"app": null,
|
|
4080
4103
|
"looks": []
|
|
4081
4104
|
},
|
|
4105
|
+
{
|
|
4106
|
+
"name": "RichText",
|
|
4107
|
+
"repo": "estiva-ui",
|
|
4108
|
+
"kind": "component",
|
|
4109
|
+
"importPath": "@estiva-app/ui",
|
|
4110
|
+
"sourceFile": "src/RichText.tsx",
|
|
4111
|
+
"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.",
|
|
4112
|
+
"purposeFrom": "page",
|
|
4113
|
+
"props": [
|
|
4114
|
+
{
|
|
4115
|
+
"name": "blocks",
|
|
4116
|
+
"takes": "readonly RichTextBlock[]",
|
|
4117
|
+
"required": true,
|
|
4118
|
+
"note": "The tree, already parsed."
|
|
4119
|
+
},
|
|
4120
|
+
{
|
|
4121
|
+
"name": "size",
|
|
4122
|
+
"takes": "default | small",
|
|
4123
|
+
"required": false,
|
|
4124
|
+
"note": "Default `default`, the size of text you read."
|
|
4125
|
+
},
|
|
4126
|
+
{
|
|
4127
|
+
"name": "renderText",
|
|
4128
|
+
"takes": "a handler",
|
|
4129
|
+
"required": false,
|
|
4130
|
+
"note": "Draws plain text: what is left of a run once its links are drawn."
|
|
4131
|
+
},
|
|
4132
|
+
{
|
|
4133
|
+
"name": "renderReference",
|
|
4134
|
+
"takes": "a handler",
|
|
4135
|
+
"required": false,
|
|
4136
|
+
"note": "Draws a run that stands for an address. By default its text."
|
|
4137
|
+
},
|
|
4138
|
+
{
|
|
4139
|
+
"name": "renderBlock",
|
|
4140
|
+
"takes": "a handler",
|
|
4141
|
+
"required": false,
|
|
4142
|
+
"note": "Draws a block this part has no design for (an attachment, a widget)."
|
|
4143
|
+
}
|
|
4144
|
+
],
|
|
4145
|
+
"variants": [
|
|
4146
|
+
{
|
|
4147
|
+
"prop": "size",
|
|
4148
|
+
"values": [
|
|
4149
|
+
"default",
|
|
4150
|
+
"small"
|
|
4151
|
+
]
|
|
4152
|
+
}
|
|
4153
|
+
],
|
|
4154
|
+
"ownsBehaviours": [],
|
|
4155
|
+
"status": "stable",
|
|
4156
|
+
"migrationStage": null,
|
|
4157
|
+
"docsId": "components-richtext--docs",
|
|
4158
|
+
"storyId": "components-richtext--every-block",
|
|
4159
|
+
"docPage": "src/RichText.mdx",
|
|
4160
|
+
"app": null,
|
|
4161
|
+
"looks": [
|
|
4162
|
+
"[&>: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",
|
|
4163
|
+
"[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-muted [&_ol]:list-decimal [&_ul]:list-disc",
|
|
4164
|
+
"[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border-strong [&_blockquote]:pl-2.5",
|
|
4165
|
+
"[&_: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",
|
|
4166
|
+
"[&_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",
|
|
4167
|
+
"[&_: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",
|
|
4168
|
+
"[&_em]:italic [&_strong]:font-semibold [&_u]:underline [&_u]:underline-offset-2",
|
|
4169
|
+
"text-body-2 [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4",
|
|
4170
|
+
"text-caption leading-snug [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold"
|
|
4171
|
+
]
|
|
4172
|
+
},
|
|
4173
|
+
{
|
|
4174
|
+
"name": "richTextClassName",
|
|
4175
|
+
"repo": "estiva-ui",
|
|
4176
|
+
"kind": "helper",
|
|
4177
|
+
"importPath": "@estiva-app/ui",
|
|
4178
|
+
"sourceFile": "src/RichText.tsx",
|
|
4179
|
+
"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.",
|
|
4180
|
+
"purposeFrom": "comment",
|
|
4181
|
+
"props": [],
|
|
4182
|
+
"variants": [],
|
|
4183
|
+
"ownsBehaviours": [],
|
|
4184
|
+
"status": "stable",
|
|
4185
|
+
"migrationStage": null,
|
|
4186
|
+
"docsId": "components-richtext--docs",
|
|
4187
|
+
"storyId": "components-richtext--every-block",
|
|
4188
|
+
"docPage": null,
|
|
4189
|
+
"app": null,
|
|
4190
|
+
"looks": []
|
|
4191
|
+
},
|
|
4082
4192
|
{
|
|
4083
4193
|
"name": "ScrollArea",
|
|
4084
4194
|
"repo": "estiva-ui",
|
package/src/Chip.mdx
CHANGED
|
@@ -20,8 +20,8 @@ muted tints; the label is the `chip` type token, 11px / 500.
|
|
|
20
20
|
|
|
21
21
|
## When not
|
|
22
22
|
|
|
23
|
-
- Something
|
|
24
|
-
a control.
|
|
23
|
+
- Something that does an action → **Button** (small) or **MenuItem**. A Chip
|
|
24
|
+
is not a control. It can only be a link, with `href`.
|
|
25
25
|
- A removable token in a multi-select → **InputChip** (see ChipInput).
|
|
26
26
|
- A count beside a tab label → **Tabs** draws its own mono number, not a
|
|
27
27
|
Chip.
|
|
@@ -37,6 +37,17 @@ import { Chip } from '@estiva-app/ui'
|
|
|
37
37
|
- `leadingIcon` / `trailingIcon` take a 12px icon; the slots centre it, so
|
|
38
38
|
it never rides the text baseline.
|
|
39
39
|
- A count alone works too — `label="3"`, no icon.
|
|
40
|
+
- `href` makes the chip a link to the thing it names: a real anchor with the
|
|
41
|
+
chip's round shape, so the keyboard ring is round too. A router app passes
|
|
42
|
+
`onClick`, as with **Link**. Inside a row whose **Link** has `cover`, it
|
|
43
|
+
stays clickable on its own: the row opens one thing, the chip another.
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
<div className="relative flex items-center gap-3">
|
|
47
|
+
<Link href={itemUrl} variant="plain" cover>Item one</Link>
|
|
48
|
+
<Chip href={groupUrl} label="Group" />
|
|
49
|
+
</div>
|
|
50
|
+
```
|
|
40
51
|
|
|
41
52
|
## What it owns
|
|
42
53
|
|
package/src/Chip.stories.tsx
CHANGED
|
@@ -27,6 +27,8 @@ export const WithLeadingIcon: Story = { args: { type: 'info', label: 'Replies',
|
|
|
27
27
|
export const WithTrailingIcon: Story = { args: { type: 'success', label: 'Sorted', trailingIcon: <IconArrowUp className="size-3" stroke={1.5} /> } }
|
|
28
28
|
/** A count alone. */
|
|
29
29
|
export const CountOnly: Story = { args: { type: 'neutral', label: '2' } }
|
|
30
|
+
/** A link to the thing it names. Tab to it: the ring is round, like the chip. */
|
|
31
|
+
export const AsLink: Story = { args: { type: 'neutral', label: 'Group', href: '#' } }
|
|
30
32
|
|
|
31
33
|
/** All six colour types. */
|
|
32
34
|
export const AllTypes: Story = {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the Chip page claims about `href`, pinned: without it the chip is not a
|
|
4
|
+
* link; with it the chip is a real anchor named by its label, keeps its round
|
|
5
|
+
* shape, sits above a covering link (`relative`), and hands the click to a
|
|
6
|
+
* router app through `onClick`.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import userEvent from '@testing-library/user-event'
|
|
11
|
+
import { Chip } from './Chip'
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup)
|
|
14
|
+
|
|
15
|
+
describe('Chip', () => {
|
|
16
|
+
it('is not a link without href', () => {
|
|
17
|
+
render(<Chip label="Group" />)
|
|
18
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
19
|
+
expect(screen.getByText('Group').parentElement?.tagName).toBe('DIV')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('with href is an anchor named by its label, round, and lifted above a covering link', () => {
|
|
23
|
+
render(<Chip label="Group" href="/groups/one" />)
|
|
24
|
+
const link = screen.getByRole('link', { name: 'Group' })
|
|
25
|
+
expect(link.getAttribute('href')).toBe('/groups/one')
|
|
26
|
+
expect(link.classList.contains('rounded-full')).toBe(true)
|
|
27
|
+
expect(link.classList.contains('relative')).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('a router app takes the click through onClick', async () => {
|
|
31
|
+
const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
|
|
32
|
+
render(<Chip label="Group" href="/groups/one" onClick={onClick} />)
|
|
33
|
+
await userEvent.click(screen.getByRole('link', { name: 'Group' }))
|
|
34
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
35
|
+
})
|
|
36
|
+
})
|
package/src/Chip.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReactNode } from 'react'
|
|
1
|
+
import type { MouseEventHandler, ReactNode } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
3
|
import { CHIP_TEXT_CLASSES } from './looks'
|
|
4
4
|
|
|
@@ -20,6 +20,14 @@ export interface ChipProps {
|
|
|
20
20
|
label?: string
|
|
21
21
|
leadingIcon?: ReactNode
|
|
22
22
|
trailingIcon?: ReactNode
|
|
23
|
+
/**
|
|
24
|
+
* The chip is a link: a real anchor with the chip's own round shape, so the
|
|
25
|
+
* keyboard ring is round too. It sits above a row's covering `Link`, so the
|
|
26
|
+
* row opens one thing and the chip another (UIG-33, Katerina 28 September).
|
|
27
|
+
*/
|
|
28
|
+
href?: string
|
|
29
|
+
/** With `href`: a router app takes the click here, like `Link`. */
|
|
30
|
+
onClick?: MouseEventHandler<HTMLAnchorElement>
|
|
23
31
|
className?: string
|
|
24
32
|
}
|
|
25
33
|
|
|
@@ -32,9 +40,10 @@ const typeStyles: Record<ChipType, string> = {
|
|
|
32
40
|
error: 'bg-error-muted text-error-default signal:border signal:border-error-outline',
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, className }: ChipProps) {
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, href, onClick, className }: ChipProps) {
|
|
44
|
+
const classes = cn('inline-flex min-w-0 items-center justify-center gap-1.5 rounded-full max-h-[20px] px-2 py-1', typeStyles[type], className)
|
|
45
|
+
const inside = (
|
|
46
|
+
<>
|
|
38
47
|
{leadingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{leadingIcon}</span>}
|
|
39
48
|
{label && (
|
|
40
49
|
// A chip given a `max-w-*` cuts a long label with an ellipsis instead
|
|
@@ -48,6 +57,9 @@ export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, class
|
|
|
48
57
|
<span className={cn('min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide', CHIP_TEXT_CLASSES)}>{label}</span>
|
|
49
58
|
)}
|
|
50
59
|
{trailingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{trailingIcon}</span>}
|
|
51
|
-
|
|
60
|
+
</>
|
|
52
61
|
)
|
|
62
|
+
// `relative` lifts a linked chip above a covering link that comes before it.
|
|
63
|
+
if (href) return <a href={href} onClick={onClick} className={cn('relative', classes)}>{inside}</a>
|
|
64
|
+
return <div className={classes}>{inside}</div>
|
|
53
65
|
}
|
package/src/Link.mdx
CHANGED
|
@@ -24,6 +24,11 @@ the app decides how it navigates.
|
|
|
24
24
|
what it wraps draws itself.
|
|
25
25
|
- `external` — another site or app: it opens in a new tab, with
|
|
26
26
|
`noopener noreferrer`.
|
|
27
|
+
- **`cover`** — a row that opens one thing wherever you click it: an item in a
|
|
28
|
+
list, a row of a table. The link covers the row, and the keyboard ring goes
|
|
29
|
+
round the whole row.
|
|
30
|
+
|
|
31
|
+
<Canvas of={LinkStories.Cover} />
|
|
27
32
|
|
|
28
33
|
## When not
|
|
29
34
|
|
|
@@ -33,6 +38,13 @@ the app decides how it navigates.
|
|
|
33
38
|
- A link that looks like an icon button → **IconButton** with `href`.
|
|
34
39
|
- A word standing for a person or a thing, drawn in a small box → **InlineChip**
|
|
35
40
|
with `href`.
|
|
41
|
+
- A box that is one link → **Card** with `href`. `cover` is for a row that has
|
|
42
|
+
no frame of its own.
|
|
43
|
+
- `cover` on a row whose parts each open something different, or a row with
|
|
44
|
+
nothing to open. A row with only a second link inside it (a chip, say) is
|
|
45
|
+
fine: see How.
|
|
46
|
+
- Stretching a link over its row by hand (`after:absolute`): the lint refuses
|
|
47
|
+
it. Use `cover`.
|
|
36
48
|
|
|
37
49
|
## How
|
|
38
50
|
|
|
@@ -59,6 +71,26 @@ import { Link } from '@estiva-app/ui'
|
|
|
59
71
|
|
|
60
72
|
- `truncate` keeps a title that is a link on one line, cut with an ellipsis.
|
|
61
73
|
Its size and colour still come from the text around it.
|
|
74
|
+
|
|
75
|
+
- `cover` covers the nearest box that is `relative`: give the row `relative`.
|
|
76
|
+
The row keeps its own size, fill and hover; the link only adds the layer
|
|
77
|
+
and the ring. Pointing anywhere on the row counts as pointing at the link,
|
|
78
|
+
so `quiet` underlines the title while the row is hovered.
|
|
79
|
+
- A second link inside the row comes **after** the covering link and is
|
|
80
|
+
`relative` itself, so it sits above the layer. A **Chip** with `href` already
|
|
81
|
+
is.
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
<div className="relative flex h-9 items-center gap-3 px-3 hover:bg-bg-hover">
|
|
85
|
+
<Link href={itemUrl} variant="plain" cover onClick={navigate}>Item one</Link>
|
|
86
|
+
<Chip href={groupUrl} label="Group" onClick={navigate} />
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<tr className="relative">
|
|
90
|
+
<td><Link href={itemUrl} variant="quiet" cover>Item one</Link></td>
|
|
91
|
+
<td>…</td>
|
|
92
|
+
</tr>
|
|
93
|
+
```
|
|
62
94
|
- `quiet`, `underlined` and `plain` take their size and colour from where they
|
|
63
95
|
sit, so put those on your own element around the link, not on the link —
|
|
64
96
|
the lint refuses a look passed in `className`.
|
package/src/Link.stories.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { Link } from './Link'
|
|
3
3
|
import { Card } from './Card'
|
|
4
|
+
import { Chip } from './Chip'
|
|
4
5
|
|
|
5
6
|
const meta = {
|
|
6
7
|
title: 'Navigation/Link',
|
|
@@ -70,6 +71,54 @@ export const External: Story = {
|
|
|
70
71
|
),
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
const ROWS = [
|
|
75
|
+
{ key: 'A-1', title: 'Item one', group: 'Group', time: '2h' },
|
|
76
|
+
{ key: 'A-2', title: 'Item two', group: 'Group', time: '1d' },
|
|
77
|
+
{ key: 'A-3', title: 'Item three', time: '3d' },
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A row that is one link: click anywhere on it, or Tab to it and the ring goes
|
|
82
|
+
* round the whole row. The chip is a second link, and opens something else.
|
|
83
|
+
*/
|
|
84
|
+
export const Cover: Story = {
|
|
85
|
+
parameters: { controls: { disable: true } },
|
|
86
|
+
render: () => (
|
|
87
|
+
<div className="flex w-full max-w-xl flex-col">
|
|
88
|
+
{ROWS.map((row) => (
|
|
89
|
+
<div key={row.key} className="relative flex h-9 items-center gap-3 rounded-md px-3 transition-colors hover:bg-bg-hover">
|
|
90
|
+
<Link href="#" variant="plain" cover className="flex min-w-0 flex-1 items-center gap-3">
|
|
91
|
+
<span className="w-10 shrink-0 font-mono text-caption text-text-muted">{row.key}</span>
|
|
92
|
+
<span className="min-w-0 flex-1 truncate text-body-2 text-text-primary">{row.title}</span>
|
|
93
|
+
</Link>
|
|
94
|
+
{row.group && <Chip href="#" label={row.group} />}
|
|
95
|
+
<span className="w-8 shrink-0 text-right text-caption text-text-muted">{row.time}</span>
|
|
96
|
+
</div>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The same in a table: the row is `relative`, the title a `quiet` link that covers it. */
|
|
103
|
+
export const CoverInTable: Story = {
|
|
104
|
+
parameters: { controls: { disable: true } },
|
|
105
|
+
render: () => (
|
|
106
|
+
<table className="w-full max-w-xl text-body-2">
|
|
107
|
+
<tbody>
|
|
108
|
+
{ROWS.map((row) => (
|
|
109
|
+
<tr key={row.key} className="relative border-b border-border-subtle last:border-b-0 hover:bg-bg-hover">
|
|
110
|
+
<td className="w-14 px-3 py-2 font-mono text-caption text-text-muted">{row.key}</td>
|
|
111
|
+
<td className="px-3 py-2 text-text-primary">
|
|
112
|
+
<Link href="#" variant="quiet" cover>{row.title}</Link>
|
|
113
|
+
</td>
|
|
114
|
+
<td className="w-16 px-3 py-2 text-right text-caption text-text-muted">{row.time}</td>
|
|
115
|
+
</tr>
|
|
116
|
+
))}
|
|
117
|
+
</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
|
|
73
122
|
/** The four looks, each where it belongs. */
|
|
74
123
|
export const AllVariants: Story = {
|
|
75
124
|
parameters: { controls: { disable: true } },
|
package/src/Link.test.tsx
CHANGED
|
@@ -88,6 +88,21 @@ describe('Link', () => {
|
|
|
88
88
|
expect(classesOf(screen.getByRole('link'))).toEqual([])
|
|
89
89
|
})
|
|
90
90
|
|
|
91
|
+
it('cover lays a layer over the row and moves the ring onto it; without it, nothing of the kind', () => {
|
|
92
|
+
render(
|
|
93
|
+
<>
|
|
94
|
+
<Link href="#" variant="plain" cover>
|
|
95
|
+
Row
|
|
96
|
+
</Link>
|
|
97
|
+
<Link href="#" variant="plain">
|
|
98
|
+
Words
|
|
99
|
+
</Link>
|
|
100
|
+
</>,
|
|
101
|
+
)
|
|
102
|
+
expect(classesOf(screen.getByRole('link', { name: 'Row' }))).toEqual(['after:absolute', 'after:inset-0', 'after:rounded-md', 'focus-visible:outline-none', 'focus-visible:after:[outline-style:auto]'])
|
|
103
|
+
expect(classesOf(screen.getByRole('link', { name: 'Words' }))).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
|
|
91
106
|
it('a ref reaches the anchor', () => {
|
|
92
107
|
const ref = createRef<HTMLAnchorElement>()
|
|
93
108
|
render(
|
package/src/Link.tsx
CHANGED
|
@@ -18,6 +18,15 @@ import { cn } from './cn'
|
|
|
18
18
|
* and a solid version became this one, solid (her ruling, 13 September).
|
|
19
19
|
* - `plain` — no look at all: a card or a row that is a link draws itself.
|
|
20
20
|
*
|
|
21
|
+
* **`cover`: a row that is one link** (UIG-33, Katerina 28 September). The link
|
|
22
|
+
* stretches an invisible layer over the nearest box the app has given
|
|
23
|
+
* `relative` (a row, a table row), so a click anywhere on it opens the link, and
|
|
24
|
+
* the keyboard ring goes round that whole box rather than round the words. The
|
|
25
|
+
* row stays the app's own: its size, its fill, its hover. A second link inside
|
|
26
|
+
* the row stays clickable when it comes after the covering link and is
|
|
27
|
+
* `relative` itself, which a `Chip` with an `href` already is. Both apps
|
|
28
|
+
* stretched a link by hand before this, each under a written exception.
|
|
29
|
+
*
|
|
21
30
|
* **Navigation is the app's.** The package cannot know a router, so like
|
|
22
31
|
* `NavItem` it renders a real anchor and passes every anchor prop through: a
|
|
23
32
|
* router app hands in `onClick`, prevents the default and navigates, and the
|
|
@@ -25,6 +34,13 @@ import { cn } from './cn'
|
|
|
25
34
|
*/
|
|
26
35
|
export type LinkVariant = 'text' | 'quiet' | 'underlined' | 'plain'
|
|
27
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The covering layer, and the ring moved onto it: `outline-none` takes the ring
|
|
39
|
+
* off the words, and `outline-style: auto` draws the browser's own ring round the
|
|
40
|
+
* layer, the same ring every other control shows, only the size of the row.
|
|
41
|
+
*/
|
|
42
|
+
const COVER_CLASSES = 'after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:[outline-style:auto]'
|
|
43
|
+
|
|
28
44
|
const VARIANT_CLASSES: Record<LinkVariant, string> = {
|
|
29
45
|
text: 'text-info-default underline underline-offset-2 hover:opacity-80',
|
|
30
46
|
quiet: 'hover:underline',
|
|
@@ -46,15 +62,20 @@ export interface LinkProps extends ComponentPropsWithRef<'a'> {
|
|
|
46
62
|
* Its size and colour still come from the text around it (UIG-9, 17 September).
|
|
47
63
|
*/
|
|
48
64
|
truncate?: boolean
|
|
65
|
+
/**
|
|
66
|
+
* The link covers the row it sits in: a click anywhere on the row opens it,
|
|
67
|
+
* and the keyboard ring goes round the whole row. Give the row `relative`.
|
|
68
|
+
*/
|
|
69
|
+
cover?: boolean
|
|
49
70
|
children: ReactNode
|
|
50
71
|
}
|
|
51
72
|
|
|
52
|
-
export function Link({ href, variant = 'text', external = false, truncate = false, className, children, ...props }: LinkProps) {
|
|
73
|
+
export function Link({ href, variant = 'text', external = false, truncate = false, cover = false, className, children, ...props }: LinkProps) {
|
|
53
74
|
return (
|
|
54
75
|
<a
|
|
55
76
|
href={href}
|
|
56
77
|
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
57
|
-
className={cn(VARIANT_CLASSES[variant], truncate && 'truncate', className)}
|
|
78
|
+
className={cn(VARIANT_CLASSES[variant], truncate && 'truncate', cover && COVER_CLASSES, className)}
|
|
58
79
|
{...props}
|
|
59
80
|
>
|
|
60
81
|
{children}
|
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
|
+
}
|