@estiva-app/ui 0.18.0 → 0.20.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/README.md +13 -5
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/Breadcrumb.d.ts.map +1 -1
- package/dist/Button.d.ts +6 -1
- package/dist/Button.d.ts.map +1 -1
- package/dist/Card.d.ts +6 -1
- package/dist/Card.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +16 -1
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/Divider.d.ts +7 -0
- package/dist/Divider.d.ts.map +1 -1
- package/dist/IconButton.d.ts +12 -2
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Link.d.ts +6 -1
- package/dist/Link.d.ts.map +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Popover.d.ts.map +1 -1
- package/dist/ReactionPicker.d.ts.map +1 -1
- package/dist/ScrollArea.d.ts +6 -0
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/SectionLabel.d.ts +6 -1
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +4 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +968 -3
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-rebuilt-behaviour.d.ts +88 -0
- package/dist/eslint/no-rebuilt-behaviour.d.ts.map +1 -0
- package/dist/eslint/no-restyled-part.d.ts +81 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -0
- package/dist/index.js +300 -268
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AttachmentCard.tsx +7 -2
- package/src/Banner.tsx +2 -2
- package/src/Breadcrumb.tsx +9 -4
- package/src/Button.mdx +7 -0
- package/src/Button.stories.tsx +4 -1
- package/src/Button.tsx +10 -2
- package/src/Card.mdx +7 -0
- package/src/Card.stories.tsx +11 -0
- package/src/Card.tsx +8 -0
- package/src/Checkbox.mdx +20 -1
- package/src/Checkbox.stories.tsx +44 -0
- package/src/Checkbox.test.tsx +63 -0
- package/src/Checkbox.tsx +30 -1
- package/src/CommandPalette.tsx +1 -1
- package/src/ConfirmDialog.tsx +2 -2
- package/src/DialogShell.tsx +6 -7
- package/src/Divider.mdx +3 -0
- package/src/Divider.tsx +12 -2
- package/src/EditableText.mdx +5 -0
- package/src/IconButton.mdx +9 -0
- package/src/IconButton.stories.tsx +10 -1
- package/src/IconButton.tsx +22 -3
- package/src/IdentityMenu.tsx +4 -1
- package/src/Link.mdx +6 -0
- package/src/Link.stories.tsx +12 -0
- package/src/Link.tsx +7 -2
- package/src/Menu.stories.tsx +1 -1
- package/src/Menu.test.tsx +2 -2
- package/src/Menu.tsx +6 -3
- package/src/Person.stories.tsx +5 -5
- package/src/Popover.test.tsx +2 -2
- package/src/Popover.tsx +3 -2
- package/src/PreviewCard.tsx +1 -1
- package/src/Property.stories.tsx +2 -2
- package/src/ReactionPicker.tsx +6 -3
- package/src/ScrollArea.stories.tsx +38 -6
- package/src/ScrollArea.tsx +7 -1
- package/src/SectionLabel.mdx +3 -2
- package/src/SectionLabel.stories.tsx +3 -0
- package/src/SectionLabel.tsx +7 -2
- package/src/Tooltip.tsx +12 -14
- package/src/eslint/index.test.ts +58 -9
- package/src/eslint/index.ts +19 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +276 -0
- package/src/eslint/no-rebuilt-behaviour.ts +593 -0
- package/src/eslint/no-restyled-part.test.ts +301 -0
- package/src/eslint/no-restyled-part.ts +647 -0
- package/stories/Choosing.mdx +1 -1
package/src/IconButton.tsx
CHANGED
|
@@ -14,11 +14,21 @@ import { TooltipTrigger } from './Tooltip'
|
|
|
14
14
|
* `disabledReason` disables it, keeps it reachable by keyboard, and shows
|
|
15
15
|
* the reason in place of the tooltip — see Button.
|
|
16
16
|
*/
|
|
17
|
-
export type IconButtonVariant = 'muted' | 'outlined' | 'primary'
|
|
17
|
+
export type IconButtonVariant = 'muted' | 'outlined' | 'primary' | 'current' | 'resolve'
|
|
18
18
|
|
|
19
19
|
/** `ComponentPropsWithRef` so a `ref` reaches the element — see Button. */
|
|
20
20
|
export interface IconButtonProps extends ComponentPropsWithRef<'button'> {
|
|
21
|
+
/**
|
|
22
|
+
* `current` takes the colour of what it sits in — the ✕ on a `Banner` wears
|
|
23
|
+
* the banner's tone. `resolve` is `muted` that turns green when pointed at in
|
|
24
|
+
* Signal, Peek's Resolve (UIG-9, 17 September; before, each app passed these
|
|
25
|
+
* as classes).
|
|
26
|
+
*/
|
|
21
27
|
variant?: IconButtonVariant
|
|
28
|
+
/** On, like a Bold button while the selection is bold: the active fill, and `aria-pressed`. */
|
|
29
|
+
pressed?: boolean
|
|
30
|
+
/** A glow in Signal — the send arrow while there is something to send. */
|
|
31
|
+
glow?: boolean
|
|
22
32
|
tooltip?: string
|
|
23
33
|
/** A key hint drawn as the `Kbd` chip inside the tooltip — for a button
|
|
24
34
|
* whose only other affordance is a keyboard shortcut. */
|
|
@@ -33,6 +43,8 @@ export interface IconButtonProps extends ComponentPropsWithRef<'button'> {
|
|
|
33
43
|
|
|
34
44
|
export function IconButton({
|
|
35
45
|
variant = 'muted',
|
|
46
|
+
pressed,
|
|
47
|
+
glow = false,
|
|
36
48
|
className,
|
|
37
49
|
children,
|
|
38
50
|
disabled,
|
|
@@ -65,11 +77,17 @@ export function IconButton({
|
|
|
65
77
|
// stretched and carries no `self-center` — see the note there.
|
|
66
78
|
'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 self-center cursor-pointer',
|
|
67
79
|
!state.disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
|
|
68
|
-
!state.disabled && variant === 'muted' && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
|
|
80
|
+
!state.disabled && (variant === 'muted' || variant === 'resolve') && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
|
|
69
81
|
!state.disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
|
|
82
|
+
!state.disabled && variant === 'current' && 'hover:bg-bg-hover',
|
|
70
83
|
state.disabled && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
|
|
71
|
-
state.disabled && variant === 'muted' && 'text-text-disabled',
|
|
84
|
+
state.disabled && (variant === 'muted' || variant === 'resolve') && 'text-text-disabled',
|
|
72
85
|
state.disabled && variant === 'outlined' && 'border border-border-default text-text-disabled',
|
|
86
|
+
// Always, disabled too: the colour of where it sits, and Resolve's green.
|
|
87
|
+
variant === 'current' && 'text-current',
|
|
88
|
+
variant === 'resolve' && 'signal:hover:bg-success-muted signal:hover:text-success-default',
|
|
89
|
+
pressed && 'bg-bg-active text-text-primary',
|
|
90
|
+
glow && 'signal:shadow-glow-accent',
|
|
73
91
|
// `pointer-events-none` only where the button is truly out of reach:
|
|
74
92
|
// with a `disabledReason` the button IS the tooltip's trigger, and a
|
|
75
93
|
// trigger the pointer cannot land on never opens one. Base UI already
|
|
@@ -79,6 +97,7 @@ export function IconButton({
|
|
|
79
97
|
className,
|
|
80
98
|
)
|
|
81
99
|
}
|
|
100
|
+
aria-pressed={pressed}
|
|
82
101
|
{...props}
|
|
83
102
|
>
|
|
84
103
|
{children}
|
package/src/IdentityMenu.tsx
CHANGED
|
@@ -95,7 +95,10 @@ export function IdentityRows({ me, signedIn, relayUrl, idBase, onCopyKey, onSign
|
|
|
95
95
|
<>
|
|
96
96
|
<MenuSection label={signedIn ? 'Signed in as' : 'Acting as'}>
|
|
97
97
|
<MenuRow>
|
|
98
|
-
|
|
98
|
+
{/* A name takes the size of where it sits (UIG-9). */}
|
|
99
|
+
<div className="flex min-w-0 text-body-2-strong">
|
|
100
|
+
<Person name={me.name} picture={me.picture} fallback="Anonymous" size={28} />
|
|
101
|
+
</div>
|
|
99
102
|
</MenuRow>
|
|
100
103
|
<Note>
|
|
101
104
|
{signedIn
|
package/src/Link.mdx
CHANGED
|
@@ -54,6 +54,12 @@ import { Link } from '@estiva-app/ui'
|
|
|
54
54
|
draw the border, the fill and the hover.
|
|
55
55
|
- If your router also exports a `Link`, rename one of them on import.
|
|
56
56
|
|
|
57
|
+
- `truncate` keeps a title that is a link on one line, cut with an ellipsis.
|
|
58
|
+
Its size and colour still come from the text around it.
|
|
59
|
+
- `quiet`, `underlined` and `plain` take their size and colour from where they
|
|
60
|
+
sit, so put those on your own element around the link, not on the link —
|
|
61
|
+
the lint refuses a look passed in `className` (UIG-9).
|
|
62
|
+
|
|
57
63
|
## Props
|
|
58
64
|
|
|
59
65
|
<Controls of={LinkStories.Text} />
|
package/src/Link.stories.tsx
CHANGED
|
@@ -100,3 +100,15 @@ export const AllVariants: Story = {
|
|
|
100
100
|
</div>
|
|
101
101
|
),
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
/** `truncate`: a title that is a link, on one line. Its size and colour come from the box around it. */
|
|
105
|
+
export const Truncate: Story = {
|
|
106
|
+
parameters: { controls: { disable: true } },
|
|
107
|
+
render: () => (
|
|
108
|
+
<div className="w-[200px] text-body-2 text-text-primary">
|
|
109
|
+
<Link href="#" variant="quiet" truncate className="block">
|
|
110
|
+
Assignee avatar and full name is missing in the files panel
|
|
111
|
+
</Link>
|
|
112
|
+
</div>
|
|
113
|
+
),
|
|
114
|
+
}
|
package/src/Link.tsx
CHANGED
|
@@ -41,15 +41,20 @@ export interface LinkProps extends ComponentPropsWithRef<'a'> {
|
|
|
41
41
|
* noreferrer` so the page it opens cannot reach back into this one.
|
|
42
42
|
*/
|
|
43
43
|
external?: boolean
|
|
44
|
+
/**
|
|
45
|
+
* One line, cut with an ellipsis when it does not fit — a title that is a link.
|
|
46
|
+
* Its size and colour still come from the text around it (UIG-9, 17 September).
|
|
47
|
+
*/
|
|
48
|
+
truncate?: boolean
|
|
44
49
|
children: ReactNode
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
export function Link({ href, variant = 'text', external = false, className, children, ...props }: LinkProps) {
|
|
52
|
+
export function Link({ href, variant = 'text', external = false, truncate = false, className, children, ...props }: LinkProps) {
|
|
48
53
|
return (
|
|
49
54
|
<a
|
|
50
55
|
href={href}
|
|
51
56
|
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
52
|
-
className={cn(VARIANT_CLASSES[variant], className)}
|
|
57
|
+
className={cn(VARIANT_CLASSES[variant], truncate && 'truncate', className)}
|
|
53
58
|
{...props}
|
|
54
59
|
>
|
|
55
60
|
{children}
|
package/src/Menu.stories.tsx
CHANGED
|
@@ -73,7 +73,7 @@ export const WithARow: Story = {
|
|
|
73
73
|
<MenuPanel className="w-64">
|
|
74
74
|
<MenuRow>
|
|
75
75
|
<span className="min-w-0 flex-1 truncate text-body-2 text-text-primary">Item one</span>
|
|
76
|
-
<SectionLabel
|
|
76
|
+
<SectionLabel tone="secondary">Label</SectionLabel>
|
|
77
77
|
</MenuRow>
|
|
78
78
|
<Divider className="my-1" />
|
|
79
79
|
<MenuItem label="An action" onClick={() => {}} />
|
package/src/Menu.test.tsx
CHANGED
|
@@ -339,8 +339,8 @@ describe('Menu, padding', () => {
|
|
|
339
339
|
)
|
|
340
340
|
await userEvent.click(screen.getByRole('button', { name: 'Open' }))
|
|
341
341
|
const row = await screen.findByRole('menuitem', { name: 'Item one' })
|
|
342
|
-
// The scrolling content is the box
|
|
343
|
-
const content = row.
|
|
342
|
+
// The scrolling content is the box the rows sit in directly.
|
|
343
|
+
const content = row.parentElement as HTMLElement
|
|
344
344
|
const classes = content.className.split(/\s+/)
|
|
345
345
|
expect(classes).toContain('p-1')
|
|
346
346
|
expect(classes).not.toContain('p-2')
|
package/src/Menu.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { createContext, useContext, type ComponentPropsWithRef, type ReactElemen
|
|
|
2
2
|
import { IconChevronRight } from '@tabler/icons-react'
|
|
3
3
|
import { Menu as BaseMenu } from '@base-ui/react/menu'
|
|
4
4
|
import { cn } from './cn'
|
|
5
|
+
import { DividerInPanel } from './Divider'
|
|
5
6
|
import { triggerDisabled } from './triggerDisabled'
|
|
6
7
|
import { ScrollArea } from './ScrollArea'
|
|
7
8
|
import { Kbd } from './Kbd'
|
|
@@ -241,8 +242,10 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
|
|
|
241
242
|
* sentence was written at 0.12.6 and the prop was not: a `p-1` on
|
|
242
243
|
* `className` added 4px to these 8px instead of replacing them
|
|
243
244
|
* (PLAN Finding 60). The prop exists now. */}
|
|
244
|
-
<ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName={cn('flex flex-col p-2
|
|
245
|
-
<MenuContext.Provider value={{ openOnHover }}>
|
|
245
|
+
<ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName={cn('flex flex-col p-2', contentClassName)}>
|
|
246
|
+
<MenuContext.Provider value={{ openOnHover }}>
|
|
247
|
+
<DividerInPanel.Provider value={true}>{children}</DividerInPanel.Provider>
|
|
248
|
+
</MenuContext.Provider>
|
|
246
249
|
</ScrollArea>
|
|
247
250
|
</BaseMenu.Popup>
|
|
248
251
|
</BaseMenu.Positioner>
|
|
@@ -561,7 +564,7 @@ export function MenuSection({ label, children, className }: { label: string; chi
|
|
|
561
564
|
const inMenu = useContext(MenuContext) !== null
|
|
562
565
|
const heading = (
|
|
563
566
|
<div className={cn('flex h-8 items-center px-2', className)}>
|
|
564
|
-
<SectionLabel
|
|
567
|
+
<SectionLabel tone="secondary">{label}</SectionLabel>
|
|
565
568
|
</div>
|
|
566
569
|
)
|
|
567
570
|
if (!inMenu) {
|
package/src/Person.stories.tsx
CHANGED
|
@@ -26,15 +26,15 @@ export const OwnFallback: Story = {
|
|
|
26
26
|
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** The text size is the
|
|
29
|
+
/** The text size is where it sits: the box around it sets the words, `size` sets the face. */
|
|
30
30
|
export const Sizes: Story = {
|
|
31
31
|
parameters: { controls: { disable: true } },
|
|
32
32
|
render: () => (
|
|
33
33
|
<div className="flex flex-col items-start gap-3">
|
|
34
|
-
<Person name="Ana Duarte" size={16}
|
|
35
|
-
<Person name="Ana Duarte" size={20}
|
|
36
|
-
<Person name="Ravi Mehta" size={22}
|
|
37
|
-
<Person name="Ravi Mehta" size={28}
|
|
34
|
+
<div className="flex text-caption"><Person name="Ana Duarte" size={16} /></div>
|
|
35
|
+
<div className="flex text-body-2"><Person name="Ana Duarte" size={20} /></div>
|
|
36
|
+
<div className="flex text-body-2"><Person name="Ravi Mehta" size={22} /></div>
|
|
37
|
+
<div className="flex text-body-1"><Person name="Ravi Mehta" size={28} /></div>
|
|
38
38
|
</div>
|
|
39
39
|
),
|
|
40
40
|
}
|
package/src/Popover.test.tsx
CHANGED
|
@@ -233,8 +233,8 @@ describe('Popover, centred on its anchor', () => {
|
|
|
233
233
|
* as on 0.12.5.
|
|
234
234
|
*/
|
|
235
235
|
describe('Popover, padding', () => {
|
|
236
|
-
// The scrolling content is the box
|
|
237
|
-
const contentOf = (child: HTMLElement) => child.
|
|
236
|
+
// The scrolling content is the box the children sit in directly.
|
|
237
|
+
const contentOf = (child: HTMLElement) => child.parentElement as HTMLElement
|
|
238
238
|
|
|
239
239
|
it('pads its content 8px by default', async () => {
|
|
240
240
|
render(
|
package/src/Popover.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useMemo, type ReactElement, type ReactNode, type RefObject } from 'react'
|
|
2
2
|
import { Popover as BasePopover } from '@base-ui/react/popover'
|
|
3
3
|
import { cn } from './cn'
|
|
4
|
+
import { DividerInPanel } from './Divider'
|
|
4
5
|
import { triggerDisabled } from './triggerDisabled'
|
|
5
6
|
import { ScrollArea } from './ScrollArea'
|
|
6
7
|
import { MenuPanel } from './Menu'
|
|
@@ -200,9 +201,9 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
|
|
|
200
201
|
— see the props. */}
|
|
201
202
|
<ScrollArea
|
|
202
203
|
viewportClassName={maxHeight ?? 'max-h-[var(--available-height)]'}
|
|
203
|
-
contentClassName={cn('flex flex-col p-2
|
|
204
|
+
contentClassName={cn('flex flex-col p-2', contentClassName)}
|
|
204
205
|
>
|
|
205
|
-
{children}
|
|
206
|
+
<DividerInPanel.Provider value={true}>{children}</DividerInPanel.Provider>
|
|
206
207
|
</ScrollArea>
|
|
207
208
|
</BasePopover.Popup>
|
|
208
209
|
</BasePopover.Positioner>
|
package/src/PreviewCard.tsx
CHANGED
|
@@ -83,7 +83,7 @@ export function PreviewCard({ content, children, side = 'right', delay = OPEN_DE
|
|
|
83
83
|
className={cn('w-[360px] p-3 outline-none', className)}
|
|
84
84
|
render={<MenuPanel />}
|
|
85
85
|
>
|
|
86
|
-
<ScrollArea viewportClassName="max-h-[calc(min(300px,var(--available-height))_-_1.5rem)]" contentClassName="flex flex-col gap-3
|
|
86
|
+
<ScrollArea viewportClassName="max-h-[calc(min(300px,var(--available-height))_-_1.5rem)]" contentClassName="flex flex-col gap-3">
|
|
87
87
|
{content}
|
|
88
88
|
</ScrollArea>
|
|
89
89
|
</BasePreviewCard.Popup>
|
package/src/Property.stories.tsx
CHANGED
|
@@ -28,7 +28,7 @@ export const APanel: Story = {
|
|
|
28
28
|
<Chip type="success" label="Done" />
|
|
29
29
|
</Property>
|
|
30
30
|
<Property label="Lead">
|
|
31
|
-
<Person name="Ana Duarte" size={16}
|
|
31
|
+
<div className="flex text-caption text-text-primary"><Person name="Ana Duarte" size={16} /></div>
|
|
32
32
|
</Property>
|
|
33
33
|
<Property label="Updated">
|
|
34
34
|
<span className="text-caption text-text-secondary">2d ago</span>
|
|
@@ -42,7 +42,7 @@ export const Stacked: Story = {
|
|
|
42
42
|
args: {
|
|
43
43
|
label: 'Assignee',
|
|
44
44
|
layout: 'stacked',
|
|
45
|
-
children: <
|
|
45
|
+
children: <div className="flex text-body-2 text-text-primary"><Person name="Ravi Mehta" size={20} /></div>,
|
|
46
46
|
},
|
|
47
47
|
}
|
|
48
48
|
|
package/src/ReactionPicker.tsx
CHANGED
|
@@ -77,10 +77,13 @@ export function ReactionPicker({ options, onSelect, 'aria-label': ariaLabel = 'R
|
|
|
77
77
|
/* Peek's geometry, verbatim: a 28px square rather than the
|
|
78
78
|
IconButton's 24, and the emoji at 18px — larger than a `Reaction`
|
|
79
79
|
pill's 16, because here the emoji is the whole control. */
|
|
80
|
-
className="size-7
|
|
80
|
+
className="size-7"
|
|
81
81
|
>
|
|
82
|
-
{/* Decorative, as on `Reaction`: the control is named above.
|
|
83
|
-
|
|
82
|
+
{/* Decorative, as on `Reaction`: the control is named above. Its size is
|
|
83
|
+
on the emoji itself, not pushed into the button (UIG-9). */}
|
|
84
|
+
<span aria-hidden="true" className="text-h3 leading-none">
|
|
85
|
+
{option.emoji}
|
|
86
|
+
</span>
|
|
84
87
|
</ToolbarButton>
|
|
85
88
|
))}
|
|
86
89
|
</Toolbar>
|
|
@@ -17,7 +17,8 @@ const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`)
|
|
|
17
17
|
/** A list taller than its box. The bar shows while the pointer is over it or the list is moving. */
|
|
18
18
|
export const Default: Story = {
|
|
19
19
|
render: () => (
|
|
20
|
-
<
|
|
20
|
+
<div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
21
|
+
<ScrollArea className="h-full" viewportClassName="p-2">
|
|
21
22
|
<ul className="flex flex-col gap-px">
|
|
22
23
|
{rows.map((row) => (
|
|
23
24
|
<li key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
@@ -26,13 +27,35 @@ export const Default: Story = {
|
|
|
26
27
|
))}
|
|
27
28
|
</ul>
|
|
28
29
|
</ScrollArea>
|
|
30
|
+
</div>
|
|
31
|
+
),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Rows that stick to the top as the list moves under them. The bar stays above them. */
|
|
35
|
+
export const StickyHeadings: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
38
|
+
<ScrollArea className="h-full">
|
|
39
|
+
{['Group one', 'Group two'].map((group) => (
|
|
40
|
+
<div key={group} className="flex flex-col">
|
|
41
|
+
<p className="sticky top-0 z-10 bg-bg-surface px-4 py-2 text-caption text-text-secondary">{group}</p>
|
|
42
|
+
{rows.slice(0, 8).map((row) => (
|
|
43
|
+
<p key={row} className="px-4 py-1.5 text-body-2 text-text-primary">
|
|
44
|
+
{row}
|
|
45
|
+
</p>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
))}
|
|
49
|
+
</ScrollArea>
|
|
50
|
+
</div>
|
|
29
51
|
),
|
|
30
52
|
}
|
|
31
53
|
|
|
32
54
|
/** Nothing to scroll: the region draws exactly as a plain box would, and no bar. */
|
|
33
55
|
export const Fits: Story = {
|
|
34
56
|
render: () => (
|
|
35
|
-
<
|
|
57
|
+
<div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
58
|
+
<ScrollArea className="h-full" viewportClassName="p-2">
|
|
36
59
|
<ul className="flex flex-col gap-px">
|
|
37
60
|
{rows.slice(0, 4).map((row) => (
|
|
38
61
|
<li key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
@@ -41,13 +64,15 @@ export const Fits: Story = {
|
|
|
41
64
|
))}
|
|
42
65
|
</ul>
|
|
43
66
|
</ScrollArea>
|
|
67
|
+
</div>
|
|
44
68
|
),
|
|
45
69
|
}
|
|
46
70
|
|
|
47
71
|
/** A row wider than its box — a table, a board. */
|
|
48
72
|
export const Horizontal: Story = {
|
|
49
73
|
render: () => (
|
|
50
|
-
<
|
|
74
|
+
<div className="w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
75
|
+
<ScrollArea orientation="horizontal" viewportClassName="p-2">
|
|
51
76
|
<div className="flex w-max gap-2">
|
|
52
77
|
{rows.slice(0, 12).map((row) => (
|
|
53
78
|
<div key={row} className="w-[120px] shrink-0 rounded-md bg-bg-inset px-2 py-1.5 text-body-2 text-text-primary">
|
|
@@ -56,13 +81,15 @@ export const Horizontal: Story = {
|
|
|
56
81
|
))}
|
|
57
82
|
</div>
|
|
58
83
|
</ScrollArea>
|
|
84
|
+
</div>
|
|
59
85
|
),
|
|
60
86
|
}
|
|
61
87
|
|
|
62
88
|
/** Both ways, with the corner where the two bars would meet. */
|
|
63
89
|
export const Both: Story = {
|
|
64
90
|
render: () => (
|
|
65
|
-
<
|
|
91
|
+
<div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
92
|
+
<ScrollArea orientation="both" className="h-full" viewportClassName="p-2">
|
|
66
93
|
<div className="flex w-max flex-col gap-px">
|
|
67
94
|
{rows.map((row) => (
|
|
68
95
|
<div key={row} className="w-[480px] rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
@@ -71,20 +98,23 @@ export const Both: Story = {
|
|
|
71
98
|
))}
|
|
72
99
|
</div>
|
|
73
100
|
</ScrollArea>
|
|
101
|
+
</div>
|
|
74
102
|
),
|
|
75
103
|
}
|
|
76
104
|
|
|
77
105
|
/** A sideways region inside a scrolling page — a table in a content column. A wheel down over it moves the page; a swipe sideways moves the region. */
|
|
78
106
|
export const SidewaysInsideAPage: Story = {
|
|
79
107
|
render: () => (
|
|
80
|
-
<
|
|
108
|
+
<div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
109
|
+
<ScrollArea className="h-full" viewportClassName="p-2">
|
|
81
110
|
<div className="flex flex-col gap-px">
|
|
82
111
|
{rows.slice(0, 3).map((row) => (
|
|
83
112
|
<p key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
84
113
|
{row}
|
|
85
114
|
</p>
|
|
86
115
|
))}
|
|
87
|
-
<
|
|
116
|
+
<div className="my-1 rounded-md border border-border-default">
|
|
117
|
+
<ScrollArea orientation="horizontal" viewportClassName="p-2">
|
|
88
118
|
<div className="flex w-max gap-2">
|
|
89
119
|
{rows.slice(0, 12).map((row) => (
|
|
90
120
|
<div key={row} className="w-[120px] shrink-0 rounded-md bg-bg-inset px-2 py-1.5 text-body-2 text-text-primary">
|
|
@@ -93,6 +123,7 @@ export const SidewaysInsideAPage: Story = {
|
|
|
93
123
|
))}
|
|
94
124
|
</div>
|
|
95
125
|
</ScrollArea>
|
|
126
|
+
</div>
|
|
96
127
|
{rows.slice(3).map((row) => (
|
|
97
128
|
<p key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
98
129
|
{row}
|
|
@@ -100,5 +131,6 @@ export const SidewaysInsideAPage: Story = {
|
|
|
100
131
|
))}
|
|
101
132
|
</div>
|
|
102
133
|
</ScrollArea>
|
|
134
|
+
</div>
|
|
103
135
|
),
|
|
104
136
|
}
|
package/src/ScrollArea.tsx
CHANGED
|
@@ -30,6 +30,12 @@ import { cn } from './cn'
|
|
|
30
30
|
* the thin scrollbar both apps had styled by hand in their `index.css`, drawn
|
|
31
31
|
* once here instead.
|
|
32
32
|
*
|
|
33
|
+
* The bar sits above the content (`z-10`). A sticky row inside — a date line
|
|
34
|
+
* in a conversation, `sticky top-0 z-10` — is at the same level, and the bar
|
|
35
|
+
* comes after the content in the page, so it paints on top. Without it the
|
|
36
|
+
* row hid the bar wherever it crossed it: a gap in the thumb, in Peek's topic
|
|
37
|
+
* and direct-message lists (UIG-8, 16 September).
|
|
38
|
+
*
|
|
33
39
|
* `orientation` says which way the region scrolls; a table that is wider
|
|
34
40
|
* than its box scrolls `horizontal`, a list `vertical` (the default), a board
|
|
35
41
|
* `both`. A vertical region keeps its content no wider than itself, so a
|
|
@@ -58,7 +64,7 @@ export interface ScrollAreaProps {
|
|
|
58
64
|
children: ReactNode
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
const BAR = 'flex touch-none select-none rounded-full opacity-0 transition-opacity delay-300 data-[hovering]:opacity-100 data-[hovering]:delay-0 data-[scrolling]:opacity-100 data-[scrolling]:delay-0'
|
|
67
|
+
const BAR = 'z-10 flex touch-none select-none rounded-full opacity-0 transition-opacity delay-300 data-[hovering]:opacity-100 data-[hovering]:delay-0 data-[scrolling]:opacity-100 data-[scrolling]:delay-0'
|
|
62
68
|
const THUMB = 'rounded-full bg-border-strong'
|
|
63
69
|
|
|
64
70
|
export function ScrollArea({ orientation = 'vertical', className, viewportClassName, contentClassName, viewportRef, onScroll, children }: ScrollAreaProps) {
|
package/src/SectionLabel.mdx
CHANGED
|
@@ -34,8 +34,9 @@ import { SectionLabel } from '@estiva-app/ui'
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
It is a bare span: the caller owns the row it sits in and the spacing
|
|
37
|
-
around it. Read it `secondary`
|
|
38
|
-
|
|
37
|
+
around it. Read it `tone="secondary"` where it labels rather than leads — menu
|
|
38
|
+
headings and the command palette's groups do (Katerina, 2026-09-01). A colour
|
|
39
|
+
passed in `className` is refused by the lint (UIG-9).
|
|
39
40
|
|
|
40
41
|
## Props
|
|
41
42
|
|
package/src/SectionLabel.tsx
CHANGED
|
@@ -9,16 +9,21 @@ import { cn } from './cn'
|
|
|
9
9
|
* micro-label under Signal. Metrics as arbitrary values for the tw-merge
|
|
10
10
|
* reason the README records.
|
|
11
11
|
*
|
|
12
|
+
* `tone="secondary"` is a heading inside a menu or a list of results: it labels
|
|
13
|
+
* the rows, it is not one of them (Katerina, 2026-09-01). `MenuSection` and
|
|
14
|
+
* `CommandPalette` passed it as a class before UIG-9 (17 September).
|
|
15
|
+
*
|
|
12
16
|
* Deliberately NOT the same thing as Property's stacked field label (the
|
|
13
17
|
* 9px `menu`-token one): they were merged for a day and unmerged by
|
|
14
18
|
* Katerina's ruling (2026-09-01) — a section title and a field label are
|
|
15
19
|
* different voices, at different sizes, on purpose.
|
|
16
20
|
*/
|
|
17
|
-
export function SectionLabel({ children, className }: { children: ReactNode; className?: string }) {
|
|
21
|
+
export function SectionLabel({ children, tone = 'primary', className }: { children: ReactNode; tone?: 'primary' | 'secondary'; className?: string }) {
|
|
18
22
|
return (
|
|
19
23
|
<span
|
|
20
24
|
className={cn(
|
|
21
|
-
'text-h5 leading-3
|
|
25
|
+
'text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest',
|
|
26
|
+
tone === 'secondary' ? 'text-text-secondary' : 'text-text-primary',
|
|
22
27
|
className,
|
|
23
28
|
)}
|
|
24
29
|
>
|
package/src/Tooltip.tsx
CHANGED
|
@@ -111,11 +111,8 @@ function TooltipSurface({ label, shortcut, placement }: { label: string; shortcu
|
|
|
111
111
|
className="pointer-events-none z-[9999]"
|
|
112
112
|
>
|
|
113
113
|
<BaseTooltip.Popup
|
|
114
|
-
render={
|
|
115
|
-
|
|
116
|
-
label={label}
|
|
117
|
-
shortcut={shortcut}
|
|
118
|
-
/*
|
|
114
|
+
render={<Tooltip label={label} shortcut={shortcut} />}
|
|
115
|
+
/*
|
|
119
116
|
* The motion (Katerina, D26, 2026-09-07). It had none — it was
|
|
120
117
|
* mounted and instantly there — and Base UI is what makes one
|
|
121
118
|
* possible: `data-starting-style` on the opening frame,
|
|
@@ -132,16 +129,17 @@ function TooltipSurface({ label, shortcut, placement }: { label: string; shortcu
|
|
|
132
129
|
* tooltip was dismissed. Sweeping a toolbar therefore moves the
|
|
133
130
|
* pill without re-animating it, which is the whole point of the
|
|
134
131
|
* shared delay (D23).
|
|
132
|
+
*
|
|
133
|
+
* On Base UI's popup, which hands its class to the pill it renders,
|
|
134
|
+
* rather than pushed into the pill from outside (UIG-9).
|
|
135
135
|
*/
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
/>
|
|
144
|
-
}
|
|
136
|
+
className={cn(
|
|
137
|
+
'transition-[opacity,transform] duration-[120ms] ease-out motion-reduce:transition-none',
|
|
138
|
+
'data-[ending-style]:duration-[80ms] data-[instant]:duration-0',
|
|
139
|
+
'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',
|
|
140
|
+
'data-[side=top]:data-[starting-style]:translate-y-[4px] data-[side=top]:data-[ending-style]:translate-y-[4px]',
|
|
141
|
+
'data-[side=bottom]:data-[starting-style]:-translate-y-[4px] data-[side=bottom]:data-[ending-style]:-translate-y-[4px]',
|
|
142
|
+
)}
|
|
145
143
|
/>
|
|
146
144
|
</BaseTooltip.Positioner>
|
|
147
145
|
</BaseTooltip.Portal>
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -6,8 +6,8 @@ import estiva, { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, PLUGIN_KEY } from '
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* The plugin as an app uses it: a flat config with `configs.recommended`,
|
|
9
|
-
* linting text the way Peek's editor hook does (`lintText`). The
|
|
10
|
-
* cases are in no-raw-element.test.ts.
|
|
9
|
+
* linting text the way Peek's editor hook does (`lintText`). The rules' own
|
|
10
|
+
* cases are in no-raw-element.test.ts and no-rebuilt-behaviour.test.ts.
|
|
11
11
|
*/
|
|
12
12
|
const tsx: Linter.Config = {
|
|
13
13
|
files: ['**/*.tsx'],
|
|
@@ -35,6 +35,8 @@ describe('the plugin object', () => {
|
|
|
35
35
|
it('carries every rule, the app ones and the inward ones', () => {
|
|
36
36
|
expect(Object.keys(estiva.rules)).toEqual([
|
|
37
37
|
'no-raw-element',
|
|
38
|
+
'no-rebuilt-behaviour',
|
|
39
|
+
'no-restyled-part',
|
|
38
40
|
'raw-element-outside-a-wrapper',
|
|
39
41
|
'no-hand-rolled-behaviour',
|
|
40
42
|
'component-has-a-page',
|
|
@@ -54,21 +56,27 @@ describe('the plugin object', () => {
|
|
|
54
56
|
it('gives an app only the app rules, as errors, under estiva/', () => {
|
|
55
57
|
for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
|
|
56
58
|
expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
57
|
-
expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error' })
|
|
59
|
+
expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error' })
|
|
58
60
|
}
|
|
59
|
-
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element'])
|
|
61
|
+
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part'])
|
|
60
62
|
})
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
/**
|
|
65
|
+
* `no-restyled-part` is the one rule in both sets (UIG-9): the package restyles
|
|
66
|
+
* none of its own parts either (Katerina, 17 September). Every other inward
|
|
67
|
+
* rule still reaches no app.
|
|
68
|
+
*/
|
|
69
|
+
it('gives this package its own set, as errors, and only the shared rule reaches an app config', () => {
|
|
63
70
|
expect(estiva.configs.package.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
64
71
|
expect(estiva.configs.package.rules).toEqual({
|
|
65
72
|
'estiva/raw-element-outside-a-wrapper': 'error',
|
|
66
73
|
'estiva/no-hand-rolled-behaviour': 'error',
|
|
67
74
|
'estiva/component-has-a-page': 'error',
|
|
68
75
|
'estiva/component-has-a-story': 'error',
|
|
76
|
+
'estiva/no-restyled-part': 'error',
|
|
69
77
|
})
|
|
70
78
|
expect(PACKAGE_RULE_IDS).toEqual(Object.keys(estiva.configs.package.rules ?? {}))
|
|
71
|
-
for (const id of PACKAGE_RULE_IDS) {
|
|
79
|
+
for (const id of PACKAGE_RULE_IDS.filter((id) => id !== 'estiva/no-restyled-part')) {
|
|
72
80
|
expect(estiva.configs.recommended.rules?.[id]).toBeUndefined()
|
|
73
81
|
expect(estiva.configs.strict.rules?.[id]).toBeUndefined()
|
|
74
82
|
}
|
|
@@ -90,6 +98,41 @@ describe('an app lint with configs.recommended', () => {
|
|
|
90
98
|
])
|
|
91
99
|
})
|
|
92
100
|
|
|
101
|
+
it('reports behaviour rebuilt by hand, naming the part', async () => {
|
|
102
|
+
const [result] = await lint(component(' <div className="h-64 overflow-y-auto" />'))
|
|
103
|
+
expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
|
|
104
|
+
['estiva/no-rebuilt-behaviour', 2, "`overflow-y-auto` scrolls with the browser's scrollbar. Use `ScrollArea` from @estiva-app/ui, which draws ours."],
|
|
105
|
+
])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* UIG-8's acceptance: a separator inside Divider is not a hand-written role.
|
|
110
|
+
* The package is exempt from the apps' rules; this runs them on Divider anyway,
|
|
111
|
+
* to show the role branch has nothing to say there (its Base UI import is the
|
|
112
|
+
* package's job, and reported only because the apps' config is not meant for it).
|
|
113
|
+
*/
|
|
114
|
+
it("finds no hand-written role in Divider's own source", async () => {
|
|
115
|
+
const source = readFileSync(new URL('../Divider.tsx', import.meta.url), 'utf8')
|
|
116
|
+
const [result] = await lint(source)
|
|
117
|
+
expect(result.messages.filter((m) => m.messageId === 'role' || m.messageId === 'roleNoPart')).toEqual([])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The probe is linted inside this package's folder, so its part is a sibling
|
|
122
|
+
* (`./Link`), the way the package's own files import one; in an app the same
|
|
123
|
+
* rule reads an import from `@estiva-app/ui` (no-restyled-part.test.ts).
|
|
124
|
+
*/
|
|
125
|
+
it('reports a part restyled, naming its look props', async () => {
|
|
126
|
+
const [result] = await lint(`import { Link } from './Link'\n${component(' <Link href="/x" className="mt-2 text-h2">x</Link>')}`)
|
|
127
|
+
expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
|
|
128
|
+
[
|
|
129
|
+
'estiva/no-restyled-part',
|
|
130
|
+
2,
|
|
131
|
+
'`text-h2` on `Link` changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in. Use its `variant` or `truncate`. A look for what is around it goes on your own element around it.',
|
|
132
|
+
],
|
|
133
|
+
])
|
|
134
|
+
})
|
|
135
|
+
|
|
93
136
|
it('passes the same element under an escape', async () => {
|
|
94
137
|
const [result] = await lint(component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'))
|
|
95
138
|
expect(result.messages).toEqual([])
|
|
@@ -99,8 +142,9 @@ describe('an app lint with configs.recommended', () => {
|
|
|
99
142
|
describe('countGates', () => {
|
|
100
143
|
it('counts an error, and an escape only when the lint reports escapes', async () => {
|
|
101
144
|
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
102
|
-
|
|
103
|
-
expect(countGates(await lint(code
|
|
145
|
+
const none = { errors: 0, warnings: 0, escapes: 0 }
|
|
146
|
+
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none })
|
|
147
|
+
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none })
|
|
104
148
|
})
|
|
105
149
|
|
|
106
150
|
it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
|
|
@@ -118,6 +162,11 @@ describe('countGates', () => {
|
|
|
118
162
|
})
|
|
119
163
|
|
|
120
164
|
it('lists every rule of the plugin, even with nothing found', () => {
|
|
121
|
-
expect(countGates([])).toEqual({
|
|
165
|
+
expect(countGates([])).toEqual({
|
|
166
|
+
rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': { errors: 0, warnings: 0, escapes: 0 },
|
|
167
|
+
'estiva/no-restyled-part': { errors: 0, warnings: 0, escapes: 0 },
|
|
168
|
+
},
|
|
169
|
+
disabled: [],
|
|
170
|
+
})
|
|
122
171
|
})
|
|
123
172
|
})
|