@estiva-app/ui 0.60.1 → 0.61.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/AppProvider.d.ts +3 -2
- package/dist/AppProvider.d.ts.map +1 -1
- package/dist/ShowMore.d.ts +7 -1
- package/dist/ShowMore.d.ts.map +1 -1
- package/dist/index.js +24 -6
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/registry.json +7 -1
- package/src/AppProvider.mdx +3 -1
- package/src/AppProvider.test.tsx +7 -0
- package/src/AppProvider.tsx +6 -3
- package/src/ShowMore.mdx +7 -0
- package/src/ShowMore.stories.tsx +14 -0
- package/src/ShowMore.test.tsx +16 -0
- package/src/ShowMore.tsx +37 -6
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"kind": "package",
|
|
5
5
|
"repo": "estiva-ui",
|
|
6
6
|
"package": "@estiva-app/ui",
|
|
7
|
-
"packageVersion": "0.
|
|
7
|
+
"packageVersion": "0.61.0",
|
|
8
8
|
"exports": 134,
|
|
9
9
|
"typeExports": 141,
|
|
10
10
|
"files": null
|
|
@@ -5872,6 +5872,12 @@
|
|
|
5872
5872
|
"required": false,
|
|
5873
5873
|
"note": "What the content sits on, for the fade to end in: a panel's `surface` (default), or an inset card's `inset`."
|
|
5874
5874
|
},
|
|
5875
|
+
{
|
|
5876
|
+
"name": "action",
|
|
5877
|
+
"takes": "anything",
|
|
5878
|
+
"required": false,
|
|
5879
|
+
"note": "A small muted `Button` with a leading icon, at the right of the toggle's row: the way out to where the content lives."
|
|
5880
|
+
},
|
|
5875
5881
|
{
|
|
5876
5882
|
"name": "className",
|
|
5877
5883
|
"takes": "text",
|
package/src/AppProvider.mdx
CHANGED
|
@@ -54,7 +54,9 @@ function Root({ children }) {
|
|
|
54
54
|
did, which is also what a story or a test sees without one.
|
|
55
55
|
- `pictureFor` gets the person's name and returns an address, or nothing for
|
|
56
56
|
the initials.
|
|
57
|
-
- `navigate` gets the link's
|
|
57
|
+
- `navigate` gets the link's path, query and `#` part (`/message/1?x=2`),
|
|
58
|
+
also when the `href` is the app's whole address (`https://app.test/message/1`):
|
|
59
|
+
a router reads a whole address as a path under the current page. The browser keeps
|
|
58
60
|
every other click: one already handled (`preventDefault`), one with Ctrl,
|
|
59
61
|
Cmd, Shift or Alt, the middle button, a link that opens a new tab or
|
|
60
62
|
downloads, and an address on another site or in another scheme
|
package/src/AppProvider.test.tsx
CHANGED
|
@@ -86,6 +86,13 @@ describe('changing page', () => {
|
|
|
86
86
|
expect(navigate).toHaveBeenCalledWith('/topics')
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
+
it("hands over the path when the href is the app's whole address", async () => {
|
|
90
|
+
const navigate = vi.fn()
|
|
91
|
+
inApp(navigate, <Link href={`${window.location.origin}/message/1?thread=2#r3`}>Message</Link>)
|
|
92
|
+
await userEvent.click(screen.getByRole('link'))
|
|
93
|
+
expect(navigate).toHaveBeenCalledWith('/message/1?thread=2#r3')
|
|
94
|
+
})
|
|
95
|
+
|
|
89
96
|
it("runs the caller's own onClick first", async () => {
|
|
90
97
|
const order: string[] = []
|
|
91
98
|
inApp(() => order.push('navigate'), <Link href="/topics" onClick={() => order.push('caller')}>Topics</Link>)
|
package/src/AppProvider.tsx
CHANGED
|
@@ -10,8 +10,9 @@ export interface AppProviderProps {
|
|
|
10
10
|
pictureFor?: (name: string) => string | null | undefined
|
|
11
11
|
/**
|
|
12
12
|
* How this app changes page without reloading. A plain click on a link to
|
|
13
|
-
* this app's own address calls it with the link's
|
|
14
|
-
* the
|
|
13
|
+
* this app's own address calls it with the link's path (`/message/1`, also
|
|
14
|
+
* when the `href` is the whole `https://…` address) instead of loading the
|
|
15
|
+
* page again: `Link` (and so `Card` and `Breadcrumb`'s crumbs),
|
|
15
16
|
* `NavItem` (and so `NavTree`'s rows), `RailItem`, `Chip`, `InlineChip`, and
|
|
16
17
|
* `IconButton` (and so `ToolbarLink`).
|
|
17
18
|
*/
|
|
@@ -71,6 +72,8 @@ export function useInAppClick<E extends HTMLElement>(href: string | undefined, o
|
|
|
71
72
|
}
|
|
72
73
|
if (url.origin !== window.location.origin) return
|
|
73
74
|
event.preventDefault()
|
|
74
|
-
|
|
75
|
+
// The path, never the whole address: a router reads `https://…` as a path
|
|
76
|
+
// under the current page and lands nowhere (Peek's message link, 8 October).
|
|
77
|
+
navigate(`${url.pathname}${url.search}${url.hash}`)
|
|
75
78
|
}
|
|
76
79
|
}
|
package/src/ShowMore.mdx
CHANGED
|
@@ -42,6 +42,13 @@ import { ShowMore } from '@estiva-app/ui'
|
|
|
42
42
|
(a panel, the default) or `inset` (inside an inset card). A fade into the
|
|
43
43
|
wrong colour shows as a band.
|
|
44
44
|
- `maxHeight` is in px, 96 by default.
|
|
45
|
+
- The toggle is a small outlined `Button` with a chevron: down on "Show more",
|
|
46
|
+
up on "Show less". Its edge starts where the content does.
|
|
47
|
+
- `action` is one more small muted `Button`, with a leading icon, on the toggle's row, at its
|
|
48
|
+
right: the way out to where the content lives ("Open in Estiva Ship"). The
|
|
49
|
+
row is drawn for it even when the content fits and there is no toggle.
|
|
50
|
+
|
|
51
|
+
<Canvas of={ShowMoreStories.WithAnAction} />
|
|
45
52
|
|
|
46
53
|
<Canvas of={ShowMoreStories.InACard} />
|
|
47
54
|
|
package/src/ShowMore.stories.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconExternalLink } from '@tabler/icons-react'
|
|
3
|
+
import { Button } from './Button'
|
|
2
4
|
import { Card } from './Card'
|
|
3
5
|
import { ShowMore } from './ShowMore'
|
|
4
6
|
|
|
@@ -37,3 +39,15 @@ export const InACard: Story = {
|
|
|
37
39
|
</Card>
|
|
38
40
|
),
|
|
39
41
|
}
|
|
42
|
+
|
|
43
|
+
/** `action`: one more small button, level with Show more at the right of its row. */
|
|
44
|
+
export const WithAnAction: Story = {
|
|
45
|
+
args: { surface: 'inset', action: <Button size="small" leadingIcon={<IconExternalLink stroke={1.5} className="size-3.5" />}>Open in Estiva Ship</Button> },
|
|
46
|
+
render: InACard.render,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Short content with an action: no toggle, and the action keeps its place at the right. */
|
|
50
|
+
export const ShortWithAnAction: Story = {
|
|
51
|
+
args: { ...WithAnAction.args, children: <p className="text-caption text-text-secondary">One line of description.</p> },
|
|
52
|
+
render: InACard.render,
|
|
53
|
+
}
|
package/src/ShowMore.test.tsx
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
7
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
8
8
|
import userEvent from '@testing-library/user-event'
|
|
9
|
+
import { Button } from './Button'
|
|
9
10
|
import { ShowMore } from './ShowMore'
|
|
10
11
|
|
|
11
12
|
afterEach(() => {
|
|
@@ -41,4 +42,19 @@ describe('ShowMore', () => {
|
|
|
41
42
|
expect(screen.queryByRole('button')).toBeNull()
|
|
42
43
|
expect(screen.getByText('Short').parentElement!.style.maxHeight).toBe('')
|
|
43
44
|
})
|
|
45
|
+
|
|
46
|
+
it('puts an action level with the toggle, at the right of its row', () => {
|
|
47
|
+
contentHeight(300)
|
|
48
|
+
render(<ShowMore maxHeight={96} action={<Button>Open</Button>}><p>Long</p></ShowMore>)
|
|
49
|
+
const row = screen.getByRole('button', { name: 'Show more' }).parentElement!
|
|
50
|
+
expect(row.contains(screen.getByRole('button', { name: 'Open' }))).toBe(true)
|
|
51
|
+
expect(row.className).toMatch(/justify-between/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('keeps the action when the content fits, with no toggle', () => {
|
|
55
|
+
contentHeight(40)
|
|
56
|
+
render(<ShowMore maxHeight={96} action={<Button>Open</Button>}><p>Short</p></ShowMore>)
|
|
57
|
+
expect(screen.queryByRole('button', { name: /Show/ })).toBeNull()
|
|
58
|
+
expect(screen.getByRole('button', { name: 'Open' })).toBeTruthy()
|
|
59
|
+
})
|
|
44
60
|
})
|
package/src/ShowMore.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { IconChevronDown, IconChevronUp } from '@tabler/icons-react'
|
|
2
3
|
import { Button } from './Button'
|
|
3
4
|
import { cn } from './cn'
|
|
4
5
|
|
|
@@ -17,6 +18,10 @@ import { cn } from './cn'
|
|
|
17
18
|
*
|
|
18
19
|
* Focus arriving inside the cut content opens it: a field or a link there,
|
|
19
20
|
* reached by Tab or a click, is never worked on out of sight.
|
|
21
|
+
*
|
|
22
|
+
* `action` is one more small button on the toggle's row, at its right
|
|
23
|
+
* (Katerina, 8 October: Peek's block card puts "Open in Estiva Ship" level with
|
|
24
|
+
* Show more). The row is drawn for it whether or not the content is cut.
|
|
20
25
|
*/
|
|
21
26
|
export interface ShowMoreProps {
|
|
22
27
|
children: ReactNode
|
|
@@ -24,12 +29,14 @@ export interface ShowMoreProps {
|
|
|
24
29
|
maxHeight?: number
|
|
25
30
|
/** What the content sits on, for the fade to end in: a panel's `surface` (default), or an inset card's `inset`. */
|
|
26
31
|
surface?: 'surface' | 'inset'
|
|
32
|
+
/** A small muted `Button` with a leading icon, at the right of the toggle's row: the way out to where the content lives. Its words end where the content does. */
|
|
33
|
+
action?: ReactNode
|
|
27
34
|
className?: string
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
const FADE_CLASSES = { surface: 'from-bg-surface', inset: 'from-bg-inset' } as const
|
|
31
38
|
|
|
32
|
-
export function ShowMore({ children, maxHeight = 96, surface = 'surface', className }: ShowMoreProps) {
|
|
39
|
+
export function ShowMore({ children, maxHeight = 96, surface = 'surface', action, className }: ShowMoreProps) {
|
|
33
40
|
const box = useRef<HTMLDivElement>(null)
|
|
34
41
|
const [open, setOpen] = useState(false)
|
|
35
42
|
const [overflows, setOverflows] = useState(false)
|
|
@@ -52,12 +59,36 @@ export function ShowMore({ children, maxHeight = 96, surface = 'surface', classN
|
|
|
52
59
|
{children}
|
|
53
60
|
{cut && <div aria-hidden className={cn('pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t to-transparent', FADE_CLASSES[surface])} />}
|
|
54
61
|
</div>
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
{
|
|
59
|
-
|
|
62
|
+
{action ? (
|
|
63
|
+
<div className={cn('mt-1 flex w-full items-center gap-2', overflows ? 'justify-between' : 'justify-end')}>
|
|
64
|
+
{overflows && <Toggle open={open} onToggle={() => setOpen((o) => !o)} />}
|
|
65
|
+
{/* `-mr-2`: a small button's 8px right inset beside a leading icon, so its words end where the content's do. */}
|
|
66
|
+
<div className="-mr-2 flex shrink-0">{action}</div>
|
|
67
|
+
</div>
|
|
68
|
+
) : (
|
|
69
|
+
overflows && <Toggle open={open} onToggle={() => setOpen((o) => !o)} className="mt-1" />
|
|
60
70
|
)}
|
|
61
71
|
</div>
|
|
62
72
|
)
|
|
63
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Outlined, with a chevron that says which way it goes (Katerina, 8 October):
|
|
77
|
+
* the one control of the part, so it has an edge. Its edge starts where the
|
|
78
|
+
* content does.
|
|
79
|
+
*/
|
|
80
|
+
function Toggle({ open, onToggle, className }: { open: boolean; onToggle: () => void; className?: string }) {
|
|
81
|
+
const Icon = open ? IconChevronUp : IconChevronDown
|
|
82
|
+
return (
|
|
83
|
+
<Button
|
|
84
|
+
variant="outlined"
|
|
85
|
+
size="small"
|
|
86
|
+
leadingIcon={<Icon stroke={1.5} className="size-3.5" />}
|
|
87
|
+
className={className}
|
|
88
|
+
aria-expanded={open}
|
|
89
|
+
onClick={onToggle}
|
|
90
|
+
>
|
|
91
|
+
{open ? 'Show less' : 'Show more'}
|
|
92
|
+
</Button>
|
|
93
|
+
)
|
|
94
|
+
}
|