@estiva-app/ui 0.35.2 → 0.37.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/Panel.d.ts +44 -0
- package/dist/Panel.d.ts.map +1 -0
- package/dist/ScrollArea.d.ts +7 -2
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/eslint/escape.d.ts +23 -6
- package/dist/eslint/escape.d.ts.map +1 -1
- package/dist/eslint/index.js +86 -12
- package/dist/eslint/index.js.map +3 -3
- package/dist/eslint/no-handmade-header.d.ts +5 -0
- package/dist/eslint/no-handmade-header.d.ts.map +1 -1
- package/dist/eslint/no-native-title.d.ts +1 -1
- package/dist/eslint/no-native-title.d.ts.map +1 -1
- package/dist/eslint/no-raw-element.d.ts +7 -0
- package/dist/eslint/no-raw-element.d.ts.map +1 -1
- package/dist/eslint/no-restyled-part.d.ts +8 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
- package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
- package/dist/gates/chunk-SYVKGOWM.js +1131 -0
- package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
- package/dist/gates/ci.d.ts +18 -0
- package/dist/gates/ci.d.ts.map +1 -0
- package/dist/gates/cli.js +20 -3
- package/dist/gates/cli.js.map +2 -2
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/escapes.d.ts +23 -0
- package/dist/gates/escapes.d.ts.map +1 -0
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/hook.d.ts +2 -0
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.d.ts +5 -1
- package/dist/gates/index.d.ts.map +1 -1
- package/dist/gates/index.js +42 -185
- package/dist/gates/index.js.map +4 -4
- package/dist/gates/name-escapes.d.ts +14 -0
- package/dist/gates/name-escapes.d.ts.map +1 -0
- package/dist/gates/status.d.ts +3 -1
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/gates/token-style.d.ts +21 -0
- package/dist/gates/token-style.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +76 -49
- package/dist/index.js.map +4 -4
- package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/behaviours.d.ts +71 -0
- package/dist/registry/behaviours.d.ts.map +1 -0
- package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
- package/dist/registry/chunk-EVJW26UR.js.map +7 -0
- package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
- package/dist/registry/chunk-SIB44CES.js.map +7 -0
- package/dist/registry/{chunk-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
- package/dist/registry/chunk-XK747STJ.js.map +7 -0
- package/dist/registry/cli.js +7 -6
- package/dist/registry/cli.js.map +2 -2
- package/dist/registry/contract.d.ts +13 -1
- package/dist/registry/contract.d.ts.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -5
- package/package.json +4 -1
- package/registry.json +369 -19
- package/skill/estiva-ui.md +2 -1
- package/src/AppShell.mdx +2 -1
- package/src/AttachmentCard.tsx +7 -7
- package/src/Button.tsx +1 -1
- package/src/Card.tsx +2 -2
- package/src/ChipInput.mdx +2 -0
- package/src/ChipInput.tsx +1 -1
- package/src/CommandPalette.tsx +3 -3
- package/src/DialogShell.tsx +1 -1
- package/src/EmptyState.mdx +4 -3
- package/src/Lightbox.tsx +1 -1
- package/src/NavItem.tsx +1 -1
- package/src/Panel.mdx +78 -0
- package/src/Panel.stories.tsx +98 -0
- package/src/Panel.tsx +80 -0
- package/src/PersonTrigger.tsx +1 -1
- package/src/Reaction.tsx +1 -1
- package/src/ScrollArea.mdx +6 -2
- package/src/ScrollArea.stories.tsx +1 -1
- package/src/ScrollArea.tsx +9 -4
- package/src/Select.tsx +1 -1
- package/src/TopBar.mdx +1 -1
- package/src/eslint/escape.ts +43 -14
- package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
- package/src/eslint/index.test.ts +2 -2
- package/src/eslint/no-copied-look.test.ts +1 -1
- package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
- package/src/eslint/no-handmade-empty-state.test.ts +2 -2
- package/src/eslint/no-handmade-header.test.ts +11 -2
- package/src/eslint/no-handmade-header.ts +24 -2
- package/src/eslint/no-native-title.test.ts +2 -2
- package/src/eslint/no-native-title.ts +1 -1
- package/src/eslint/no-raw-element.test.ts +47 -15
- package/src/eslint/no-raw-element.ts +53 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
- package/src/eslint/no-restyled-part.test.ts +21 -2
- package/src/eslint/no-restyled-part.ts +30 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
- package/src/gates/app-checks.ts +9 -6
- package/src/gates/ci.ts +152 -0
- package/src/gates/cli.ts +19 -3
- package/src/gates/count.ts +1 -1
- package/src/gates/create-app.test.ts +2 -2
- package/src/gates/create-app.ts +22 -16
- package/src/gates/escapes.ts +111 -0
- package/src/gates/gate-config.ts +2 -0
- package/src/gates/gates.test.ts +86 -1
- package/src/gates/hook.ts +38 -4
- package/src/gates/index.ts +5 -1
- package/src/gates/name-escapes.ts +77 -0
- package/src/gates/status.ts +45 -2
- package/src/gates/token-lint.ts +11 -4
- package/src/gates/token-style.ts +79 -0
- package/src/how.test.ts +282 -0
- package/src/index.ts +1 -0
- package/src/pages.test.ts +6 -0
- package/src/registry/app.test.ts +37 -1
- package/src/registry/app.ts +52 -2
- package/src/registry/behaviours.ts +223 -0
- package/src/registry/build.ts +36 -0
- package/src/registry/cli.ts +5 -3
- package/src/registry/contract.test.ts +27 -2
- package/src/registry/contract.ts +28 -3
- package/src/registry/index.ts +1 -1
- package/src/registry/registry.test.ts +18 -4
- package/stories/Choosing.mdx +4 -3
- package/dist/gates/chunk-DBMUQAR6.js +0 -607
- package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
- package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
- package/dist/registry/chunk-5K2TRR4X.js.map +0 -7
- package/dist/registry/chunk-XVII7P6G.js.map +0 -7
- /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
- /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
package/src/AttachmentCard.tsx
CHANGED
|
@@ -89,7 +89,7 @@ const formatBytes = (bytes: number) => {
|
|
|
89
89
|
const typeLabelOf = (name: string) => extensionOf(name).toUpperCase() || 'FILE'
|
|
90
90
|
|
|
91
91
|
const TILE_CLASSES = 'size-9 rounded-md bg-bg-active flex items-center justify-center shrink-0 text-text-secondary'
|
|
92
|
-
// @estiva-escape: A6 (Katerina, 24 September: record only): a 12px medium name, the text style InputChip's label also uses, not a copy of a part
|
|
92
|
+
// @estiva-escape(no-copied-look): A6 (Katerina, 24 September: record only): a 12px medium name, the text style InputChip's label also uses, not a copy of a part
|
|
93
93
|
const NAME_CLASSES = 'text-caption font-medium text-text-primary truncate'
|
|
94
94
|
const NOTE_CLASSES = 'text-small tracking-wide leading-tight truncate'
|
|
95
95
|
|
|
@@ -359,7 +359,7 @@ export function AttachmentCard({
|
|
|
359
359
|
const failed = state === 'failed'
|
|
360
360
|
const warning = state === 'warning'
|
|
361
361
|
return (
|
|
362
|
-
// @estiva-escape: a failed or warning file's strong hairline is AttachmentCard's own state; Card's attention hairlines are the soft ones (UIG-9, Katerina 17 September)
|
|
362
|
+
// @estiva-escape(no-restyled-part): a failed or warning file's strong hairline is AttachmentCard's own state; Card's attention hairlines are the soft ones (UIG-9, Katerina 17 September)
|
|
363
363
|
<Card
|
|
364
364
|
fill="elevated"
|
|
365
365
|
className={cn('group relative flex items-center gap-2 w-[200px] p-1.5 pr-3', failed ? 'border-error-default' : warning && 'border-warning-default', className)}
|
|
@@ -390,7 +390,7 @@ export function AttachmentCard({
|
|
|
390
390
|
{/* On Base UI's Button, as InputChip's ✕ is (Katerina, 2026-09-14): IconButton is a 24px square
|
|
391
391
|
that fills on hover, and this is Peek's 20px round badge on the card's corner. */}
|
|
392
392
|
{onRemove && (
|
|
393
|
-
// @estiva-escape: N4 (Katerina, 24 September: record only): the small round button Lightbox's close also draws; a part if a third appears
|
|
393
|
+
// @estiva-escape(no-copied-look): N4 (Katerina, 24 September: record only): the small round button Lightbox's close also draws; a part if a third appears
|
|
394
394
|
<BaseButton
|
|
395
395
|
type="button"
|
|
396
396
|
aria-label={`Remove ${name}`}
|
|
@@ -411,7 +411,7 @@ export function AttachmentCard({
|
|
|
411
411
|
|
|
412
412
|
// Fades in with the card's hover or keyboard focus, and keeps its slot when hidden, so revealing it never shifts the row.
|
|
413
413
|
const downloadControl = saving ? (
|
|
414
|
-
// @estiva-escape: Download shows when its card is pointed at or focused, AttachmentCard's own action; IconButton has no reveal on its card's hover (UIG-9, Katerina 17 September)
|
|
414
|
+
// @estiva-escape(no-restyled-part): Download shows when its card is pointed at or focused, AttachmentCard's own action; IconButton has no reveal on its card's hover (UIG-9, Katerina 17 September)
|
|
415
415
|
<IconButton
|
|
416
416
|
variant="muted"
|
|
417
417
|
tooltip="Download"
|
|
@@ -430,7 +430,7 @@ export function AttachmentCard({
|
|
|
430
430
|
return (
|
|
431
431
|
// `role="status"`: a plain box may not carry a name (axe, aria-prohibited-attr), and a
|
|
432
432
|
// status says what it is doing — loading — to a reader that cannot see the pulse.
|
|
433
|
-
// @estiva-escape: the loading pulse is AttachmentCard's own state; Card has no loading state (UIG-9, Katerina 17 September)
|
|
433
|
+
// @estiva-escape(no-restyled-part): the loading pulse is AttachmentCard's own state; Card has no loading state (UIG-9, Katerina 17 September)
|
|
434
434
|
<Card role="status" fill="inset" aria-busy="true" aria-label={`Loading ${name}`} className={cn('w-[180px] h-28 animate-pulse', className)} {...props}>
|
|
435
435
|
{null}
|
|
436
436
|
</Card>
|
|
@@ -439,7 +439,7 @@ export function AttachmentCard({
|
|
|
439
439
|
|
|
440
440
|
if (drawnState === 'unreadable') {
|
|
441
441
|
return (
|
|
442
|
-
// @estiva-escape: a file that could not be read is faded as well as dashed, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
|
|
442
|
+
// @estiva-escape(no-restyled-part): a file that could not be read is faded as well as dashed, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
|
|
443
443
|
<Card fill="inset" unreadable className={cn('flex items-center gap-2 w-[240px] p-1.5 opacity-70', className)} {...props}>
|
|
444
444
|
<div className={TILE_CLASSES}>
|
|
445
445
|
<TypeIcon name={name} />
|
|
@@ -494,7 +494,7 @@ export function AttachmentCard({
|
|
|
494
494
|
</>
|
|
495
495
|
)
|
|
496
496
|
return (
|
|
497
|
-
// @estiva-escape: a file that cannot be opened is faded, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
|
|
497
|
+
// @estiva-escape(no-restyled-part): a file that cannot be opened is faded, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
|
|
498
498
|
<Card fill="inset" hover={address ? 'hairline' : 'none'} className={cn('group flex items-center gap-2 w-[240px] p-1.5 pr-1', !address && 'opacity-70', className)} {...props}>
|
|
499
499
|
{address ? (
|
|
500
500
|
<Link href={address} external variant="plain" className="flex items-center gap-2 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
|
package/src/Button.tsx
CHANGED
|
@@ -92,7 +92,7 @@ export function Button({
|
|
|
92
92
|
size === 'default' && (hasLeadingIcon ? 'pl-2 pr-3' : 'px-2'),
|
|
93
93
|
size === 'small' && (hasLeadingIcon ? 'pl-1.5 pr-2' : 'px-1.5'),
|
|
94
94
|
!state.disabled && (variant === 'primary' || variant === 'resolve') && 'bg-accent-primary hover:bg-accent-hover text-text-inverse cursor-pointer signal:font-semibold',
|
|
95
|
-
// @estiva-escape: W2 (Katerina, 24 September: leave alone): an outlined button shares four words with Reaction's pill by coincidence
|
|
95
|
+
// @estiva-escape(no-copied-look): W2 (Katerina, 24 September: leave alone): an outlined button shares four words with Reaction's pill by coincidence
|
|
96
96
|
!state.disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-primary cursor-pointer',
|
|
97
97
|
!state.disabled && variant === 'muted' && 'hover:bg-bg-hover text-text-primary cursor-pointer',
|
|
98
98
|
!state.disabled && variant === 'destructive' && 'hover:bg-error-muted text-error-default cursor-pointer',
|
package/src/Card.tsx
CHANGED
|
@@ -127,14 +127,14 @@ export function Card({
|
|
|
127
127
|
if (href !== undefined) {
|
|
128
128
|
const { ref, ...anchorProps } = props as ComponentPropsWithRef<'a'>
|
|
129
129
|
return (
|
|
130
|
-
// @estiva-escape: a card that leads somewhere is drawn on Link's anchor (UIG-27 ruling 1), so this frame is Card's own look, not Link restyled
|
|
130
|
+
// @estiva-escape(no-copied-look, no-restyled-part): a card that leads somewhere is drawn on Link's anchor (UIG-27 ruling 1), so this frame is Card's own look, not Link restyled
|
|
131
131
|
<Link href={href} variant="plain" ref={ref} className={classes} {...anchorProps}>
|
|
132
132
|
{children}
|
|
133
133
|
</Link>
|
|
134
134
|
)
|
|
135
135
|
}
|
|
136
136
|
return (
|
|
137
|
-
// @estiva-escape: N6 (Katerina, 24 September: leave alone): a card that cuts its content to its corners shares four words with the palette's window by coincidence
|
|
137
|
+
// @estiva-escape(no-copied-look): N6 (Katerina, 24 September: leave alone): a card that cuts its content to its corners shares four words with the palette's window by coincidence
|
|
138
138
|
<div className={classes} {...props}>
|
|
139
139
|
{children}
|
|
140
140
|
</div>
|
package/src/ChipInput.mdx
CHANGED
package/src/ChipInput.tsx
CHANGED
|
@@ -62,7 +62,7 @@ export function InputChip({ label, leading, onRemove, removeLabel, truncate, cla
|
|
|
62
62
|
return (
|
|
63
63
|
<div className={cn(CHIP_BOX, chipPadding(!!leading, !!onRemove), className)}>
|
|
64
64
|
{leading && <span className="flex shrink-0 items-center">{leading}</span>}
|
|
65
|
-
{/* @estiva-escape: A6 (Katerina, 24 September: record only): a 12px medium label, the text style AttachmentCard's name also uses, not a copy of a part */}
|
|
65
|
+
{/* @estiva-escape(no-copied-look): A6 (Katerina, 24 September: record only): a 12px medium label, the text style AttachmentCard's name also uses, not a copy of a part */}
|
|
66
66
|
<span className={cn(CHIP_LABEL, truncate && 'min-w-0 truncate')}>{label}</span>
|
|
67
67
|
{/* Base UI's `Button`, as every button in the package is (D6). Base UI
|
|
68
68
|
has no chip of its own — its only chips are `Combobox.Chip` and
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -123,7 +123,7 @@ export function CommandPalette({ open, onOpenChange, label, where, modKey = 'Ctr
|
|
|
123
123
|
<Dialog.Portal>
|
|
124
124
|
<Dialog.Backdrop className="fixed inset-0 z-40 bg-scrim" />
|
|
125
125
|
<Dialog.Viewport className="fixed inset-0 z-50 flex items-start justify-center pt-[16vh]">
|
|
126
|
-
{/* @estiva-escape: A3 (Katerina, 24 September: leave CommandPalette alone): the palette's window keeps its own border */}
|
|
126
|
+
{/* @estiva-escape(no-copied-look): A3 (Katerina, 24 September: leave CommandPalette alone): the palette's window keeps its own border */}
|
|
127
127
|
<Dialog.Popup
|
|
128
128
|
ref={popupRef}
|
|
129
129
|
aria-label={label}
|
|
@@ -374,7 +374,7 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
|
|
|
374
374
|
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
|
|
375
375
|
<IconSearch size={16} stroke={1.5} className="shrink-0 text-text-secondary" />
|
|
376
376
|
{chip && <LevelChip chip={chip} onBack={back} />}
|
|
377
|
-
{/* @estiva-escape: A5 (Katerina, 24 September: leave CommandPalette alone): the palette's search field keeps its own classes */}
|
|
377
|
+
{/* @estiva-escape(no-copied-look): A5 (Katerina, 24 September: leave CommandPalette alone): the palette's search field keeps its own classes */}
|
|
378
378
|
<Autocomplete.Input
|
|
379
379
|
ref={inputRef}
|
|
380
380
|
data-command-palette-field=""
|
|
@@ -555,7 +555,7 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
|
|
|
555
555
|
|
|
556
556
|
return (
|
|
557
557
|
<div ref={frameRef} tabIndex={-1} onKeyDown={onKeyDown} onFocus={measure} onBlur={measure} onInput={measure} className="flex min-h-0 flex-col outline-none">
|
|
558
|
-
{/* @estiva-escape: the palette's own input bar, the twin of its search row; the level chip stands where the field was */}
|
|
558
|
+
{/* @estiva-escape(no-handmade-header): the palette's own input bar, the twin of its search row; the level chip stands where the field was */}
|
|
559
559
|
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
|
|
560
560
|
{icon != null && <span className="flex shrink-0 items-center text-text-secondary">{icon}</span>}
|
|
561
561
|
<LevelChip chip={chip} onBack={back} />
|
package/src/DialogShell.tsx
CHANGED
|
@@ -86,7 +86,7 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
|
|
|
86
86
|
|
|
87
87
|
{/* Dialog */}
|
|
88
88
|
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
|
|
89
|
-
{/* @estiva-escape: A3 (Katerina, 24 September: leave CommandPalette alone): a dialog's window and the palette's are left as they are */}
|
|
89
|
+
{/* @estiva-escape(no-copied-look): A3 (Katerina, 24 September: leave CommandPalette alone): a dialog's window and the palette's are left as they are */}
|
|
90
90
|
<Parts.Popup
|
|
91
91
|
ref={popupRef}
|
|
92
92
|
/*
|
package/src/EmptyState.mdx
CHANGED
|
@@ -36,9 +36,10 @@ need a height from the caller (`className="h-full"`).
|
|
|
36
36
|
|
|
37
37
|
## When not
|
|
38
38
|
|
|
39
|
-
- Data is still on its way → **Skeleton
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
- Data is still on its way → **Skeleton**: **SkeletonList**, or a shape of
|
|
40
|
+
your own from **SkeletonBar**; empty and loading are different truths.
|
|
41
|
+
When the shape of what arrives is unknown, an EmptyState that says so —
|
|
42
|
+
"Opening…" — is fine.
|
|
42
43
|
- Something went wrong → say what failed, in words. An EmptyState can carry
|
|
43
44
|
it, in its usual look — never red. An empty state that hides a failure
|
|
44
45
|
misleads.
|
package/src/Lightbox.tsx
CHANGED
|
@@ -74,7 +74,7 @@ export function Lightbox({ src, alt, onClose, className }: LightboxProps) {
|
|
|
74
74
|
onClick={(e) => e.stopPropagation()}
|
|
75
75
|
className={cn('max-w-full max-h-full object-contain rounded-lg', className)}
|
|
76
76
|
/>
|
|
77
|
-
{/* @estiva-escape: N4 (Katerina, 24 September: record only): the small round button AttachmentCard's remove also draws; a part if a third appears */}
|
|
77
|
+
{/* @estiva-escape(no-copied-look): N4 (Katerina, 24 September: record only): the small round button AttachmentCard's remove also draws; a part if a third appears */}
|
|
78
78
|
<Dialog.Close
|
|
79
79
|
aria-label="Close"
|
|
80
80
|
className="absolute top-4 right-4 size-8 rounded-full bg-bg-elevated border border-border-strong flex items-center justify-center text-text-secondary hover:text-text-primary"
|
package/src/NavItem.tsx
CHANGED
|
@@ -28,7 +28,7 @@ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'
|
|
|
28
28
|
|
|
29
29
|
export function NavItem({ label, href, count, countLabel, active = false, icon, className, ...props }: NavItemProps) {
|
|
30
30
|
return (
|
|
31
|
-
// @estiva-escape: N5 (Katerina, 24 September: record only): a row that fills on hover, as PersonTrigger's row also does
|
|
31
|
+
// @estiva-escape(no-copied-look): N5 (Katerina, 24 September: record only): a row that fills on hover, as PersonTrigger's row also does
|
|
32
32
|
<a
|
|
33
33
|
href={href}
|
|
34
34
|
aria-current={active ? 'page' : undefined}
|
package/src/Panel.mdx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as PanelStories from './Panel.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={PanelStories} />
|
|
5
|
+
|
|
6
|
+
# Panel
|
|
7
|
+
|
|
8
|
+
A panel of a page: its name in a header, a body that scrolls whenever it is
|
|
9
|
+
taller than the room it has, and a row fixed at the bottom when there is one.
|
|
10
|
+
|
|
11
|
+
<Canvas of={PanelStories.ALongBody} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **Any panel of a page that is not a list**: a side column's details, a
|
|
16
|
+
thread, another app's card beside a file, the contents of what is open.
|
|
17
|
+
Its body scrolls by itself, so it is never cut off at the fold, even when
|
|
18
|
+
today's content fits.
|
|
19
|
+
- `footer` for a row that stays put under the body while it scrolls: a reply
|
|
20
|
+
box.
|
|
21
|
+
- Two panels in one column share it. Cap the first with a `max-h-*` in
|
|
22
|
+
`className`, and each scrolls in its own room.
|
|
23
|
+
|
|
24
|
+
<Canvas of={PanelStories.WithAFooter} />
|
|
25
|
+
|
|
26
|
+
<Canvas of={PanelStories.TwoInAColumn} />
|
|
27
|
+
|
|
28
|
+
## When not
|
|
29
|
+
|
|
30
|
+
- A list of things to open beside the page → **ListColumn**, which is a panel
|
|
31
|
+
with the list's own width and insets.
|
|
32
|
+
- A window over the page → **DialogShell**.
|
|
33
|
+
- A box inside a panel or a card that should scroll on its own → **ScrollArea**.
|
|
34
|
+
|
|
35
|
+
## How
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { Panel, IconButton } from '@estiva-app/ui'
|
|
39
|
+
|
|
40
|
+
<Panel
|
|
41
|
+
title="Details"
|
|
42
|
+
actions={
|
|
43
|
+
<IconButton tooltip="More" aria-label="More" onClick={openMenu}>
|
|
44
|
+
<IconDotsVertical size={16} stroke={1.5} />
|
|
45
|
+
</IconButton>
|
|
46
|
+
}
|
|
47
|
+
bodyClassName="flex flex-col gap-3 px-4 pt-3 pb-4"
|
|
48
|
+
>
|
|
49
|
+
{sections}
|
|
50
|
+
</Panel>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- It takes the room its column gives it, never its content's height, so the
|
|
54
|
+
body is what scrolls. Put it straight into the column.
|
|
55
|
+
- `bodyClassName` is the body's padding, gap and layout. The header is
|
|
56
|
+
ContainerHeader's: 48px, a hairline under it.
|
|
57
|
+
- A panel that follows its scroll (a thread that opens at its newest reply)
|
|
58
|
+
reads it with `bodyRef` and `onBodyScroll`.
|
|
59
|
+
- **A crash stays inside it.** If its body, its actions or its footer break,
|
|
60
|
+
it keeps its title and shows "Something went wrong in the panel… try again"
|
|
61
|
+
in the room the body had. It is **ErrorBoundary**'s.
|
|
62
|
+
|
|
63
|
+
<Canvas of={PanelStories.WhenTheBodyBreaks} />
|
|
64
|
+
|
|
65
|
+
## What it owns
|
|
66
|
+
|
|
67
|
+
It does these by itself. Write none of them around it.
|
|
68
|
+
|
|
69
|
+
| It does | So you never write | Refused when written by hand |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| Draws the header: a ContainerHeader with the title and the actions | a header row of your own over a box | ContainerHeader used on its own in an app |
|
|
72
|
+
| Scrolls its body in a ScrollArea whenever it is taller than the panel, with the bar showing | `overflow-auto`, or a box that cuts off what does not fit | |
|
|
73
|
+
| Keeps the footer fixed under the body | a flex column of your own around the body and the footer | |
|
|
74
|
+
| Keeps a crash inside itself: its title stays, the message and Try again sit in the room the body had | an error boundary of your own around it | |
|
|
75
|
+
|
|
76
|
+
## Props
|
|
77
|
+
|
|
78
|
+
<Controls of={PanelStories.Default} />
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconDotsVertical } from '@tabler/icons-react'
|
|
3
|
+
import { IconButton } from './IconButton'
|
|
4
|
+
import { Panel } from './Panel'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A panel of a page: a header, a body that scrolls whenever it is taller than
|
|
8
|
+
* the panel, and a row fixed under it when there is one. The body's words are
|
|
9
|
+
* stand-ins.
|
|
10
|
+
*/
|
|
11
|
+
const meta = {
|
|
12
|
+
title: 'Layout/Panel',
|
|
13
|
+
component: Panel,
|
|
14
|
+
parameters: { layout: 'fullscreen' },
|
|
15
|
+
args: { title: 'Details', children: null, bodyClassName: 'flex flex-col gap-3 px-4 pt-3 pb-4' },
|
|
16
|
+
argTypes: { children: { control: false }, actions: { control: false }, footer: { control: false } },
|
|
17
|
+
// A side column of the frame: 380px wide and a fixed height, where a panel sits beside a page.
|
|
18
|
+
decorators: [
|
|
19
|
+
(Story) => (
|
|
20
|
+
<div className="flex h-[560px] w-[380px] flex-col border-l border-border-subtle bg-bg-surface">
|
|
21
|
+
{Story()}
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
],
|
|
25
|
+
} satisfies Meta<typeof Panel>
|
|
26
|
+
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof meta>
|
|
29
|
+
|
|
30
|
+
const paragraph = (n: number) => (
|
|
31
|
+
<p key={n} className="text-body-2 text-text-secondary">
|
|
32
|
+
Paragraph {n}. What this item is for, who it is for, and what it leaves for later, written out in full the way a real description is.
|
|
33
|
+
</p>
|
|
34
|
+
)
|
|
35
|
+
const paragraphs = (count: number) => Array.from({ length: count }, (_, i) => paragraph(i + 1))
|
|
36
|
+
const menu = <IconButton tooltip="More" aria-label="More"><IconDotsVertical size={16} stroke={1.5} /></IconButton>
|
|
37
|
+
|
|
38
|
+
/** A short body: it fits, so there is no bar. */
|
|
39
|
+
export const Default: Story = {
|
|
40
|
+
args: { actions: menu },
|
|
41
|
+
render: (args) => <Panel {...args}>{paragraphs(2)}</Panel>,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A title that opens something: the chevron after it, as ContainerHeader draws it. */
|
|
45
|
+
export const WithChevron: Story = {
|
|
46
|
+
args: { chevron: true },
|
|
47
|
+
render: (args) => <Panel {...args}>{paragraphs(2)}</Panel>,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A body taller than the panel: it scrolls, and the bar shows at rest because there is more below. */
|
|
51
|
+
export const ALongBody: Story = {
|
|
52
|
+
args: { actions: menu },
|
|
53
|
+
render: (args) => <Panel {...args}>{paragraphs(14)}</Panel>,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A row fixed under the body, such as a reply box: the body scrolls above it, and it stays put. */
|
|
57
|
+
export const WithAFooter: Story = {
|
|
58
|
+
render: (args) => (
|
|
59
|
+
<Panel
|
|
60
|
+
{...args}
|
|
61
|
+
footer={
|
|
62
|
+
// A stand-in for whatever the footer holds: the part only keeps it in place.
|
|
63
|
+
<div className="border-t border-border-subtle p-3">
|
|
64
|
+
<div className="flex h-10 items-center justify-center rounded-md bg-bg-elevated text-caption text-text-muted">Footer</div>
|
|
65
|
+
</div>
|
|
66
|
+
}
|
|
67
|
+
>
|
|
68
|
+
{paragraphs(12)}
|
|
69
|
+
</Panel>
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Two panels in one column: the first capped at three fifths, each scrolls in its own room. */
|
|
74
|
+
export const TwoInAColumn: Story = {
|
|
75
|
+
render: (args) => (
|
|
76
|
+
<>
|
|
77
|
+
<Panel {...args} title="Details" className="max-h-[60%]">
|
|
78
|
+
{paragraphs(10)}
|
|
79
|
+
</Panel>
|
|
80
|
+
<Panel {...args} title="Activity">
|
|
81
|
+
{paragraphs(6)}
|
|
82
|
+
</Panel>
|
|
83
|
+
</>
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function Broken(): never {
|
|
88
|
+
throw new Error('The body could not draw itself')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A crash inside it stays inside it: the title stays, and the message takes the body's room. */
|
|
92
|
+
export const WhenTheBodyBreaks: Story = {
|
|
93
|
+
render: (args) => (
|
|
94
|
+
<Panel {...args}>
|
|
95
|
+
<Broken />
|
|
96
|
+
</Panel>
|
|
97
|
+
),
|
|
98
|
+
}
|
package/src/Panel.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ReactNode, Ref, UIEventHandler } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
import { ContainerHeader } from './ContainerHeader'
|
|
4
|
+
import { ErrorBoundary } from './ErrorBoundary'
|
|
5
|
+
import { ScrollArea } from './ScrollArea'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A panel of a page: its name in a `ContainerHeader`, a body under it that
|
|
9
|
+
* scrolls whenever it is taller than the room it has, and, when there is one,
|
|
10
|
+
* a row fixed at the bottom that never scrolls — a reply box.
|
|
11
|
+
*
|
|
12
|
+
* Made so that scrolling comes from the package, not from each app remembering
|
|
13
|
+
* it (Katerina, 26 September 2026). The apps built their panels from the
|
|
14
|
+
* package's header and a box of their own, and a box without a `ScrollArea`
|
|
15
|
+
* simply cut off what did not fit: Peek's widget panel on a Ship file lost the
|
|
16
|
+
* end of a long description and the status control under it, with no bar and
|
|
17
|
+
* nothing to scroll. Here the body is always a `ScrollArea`, so the bar shows
|
|
18
|
+
* as soon as there is more, and an empty panel or a short one has none.
|
|
19
|
+
*
|
|
20
|
+
* It takes the room its column gives it (`flex-1 min-h-0`), never its content's
|
|
21
|
+
* height, so the body is what scrolls, not the page. Two panels in one column
|
|
22
|
+
* share it; a `max-h-*` in `className` caps one of them.
|
|
23
|
+
*
|
|
24
|
+
* It keeps a crash inside itself, as ListColumn does: if its body, its actions
|
|
25
|
+
* or its footer break, the panel keeps its title and shows `ErrorBoundary`'s
|
|
26
|
+
* message in the room the body had.
|
|
27
|
+
*/
|
|
28
|
+
export interface PanelProps {
|
|
29
|
+
/** The panel's name, in its header. A node for a title that is more than words. */
|
|
30
|
+
title: ReactNode
|
|
31
|
+
/** A chevron after the title, as `ContainerHeader` draws it. */
|
|
32
|
+
chevron?: boolean
|
|
33
|
+
/** The panel's own actions, at the header's right — a `Toolbar` of `ToolbarButton`s, or one `IconButton`. */
|
|
34
|
+
actions?: ReactNode
|
|
35
|
+
/** The body. It scrolls when it is taller than the panel. */
|
|
36
|
+
children: ReactNode
|
|
37
|
+
/** A row fixed under the body that never scrolls — a reply box. */
|
|
38
|
+
footer?: ReactNode
|
|
39
|
+
/** The body's own box: its padding, gap and layout — `px-4 pt-3 pb-4`, `flex flex-col gap-2`. */
|
|
40
|
+
bodyClassName?: string
|
|
41
|
+
/** The body's scrolling box, for a panel that reads or drives its scroll: a thread that opens at its newest reply. */
|
|
42
|
+
bodyRef?: Ref<HTMLDivElement>
|
|
43
|
+
/** Fires as the body scrolls. */
|
|
44
|
+
onBodyScroll?: UIEventHandler<HTMLDivElement>
|
|
45
|
+
/** Placement only: a cap such as `max-h-[60%]` when it shares a column. */
|
|
46
|
+
className?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function Frame({ title, chevron = false, actions, children, footer, bodyClassName, bodyRef, onBodyScroll, className, broken = false }: PanelProps & { broken?: boolean }) {
|
|
50
|
+
return (
|
|
51
|
+
<div className={cn('flex min-h-0 flex-1 flex-col', className)}>
|
|
52
|
+
<ContainerHeader title={title} chevron={chevron} actions={actions} />
|
|
53
|
+
{broken ? (
|
|
54
|
+
// The message takes the room the body had and centres itself in it.
|
|
55
|
+
<div className="flex min-h-0 flex-1 flex-col px-4">{children}</div>
|
|
56
|
+
) : (
|
|
57
|
+
<ScrollArea className="min-h-0 flex-1" contentClassName={bodyClassName} viewportRef={bodyRef} onScroll={onBodyScroll}>
|
|
58
|
+
{children}
|
|
59
|
+
</ScrollArea>
|
|
60
|
+
)}
|
|
61
|
+
{!broken && footer && <div className="shrink-0">{footer}</div>}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function Panel(props: PanelProps) {
|
|
67
|
+
return (
|
|
68
|
+
<ErrorBoundary
|
|
69
|
+
label="panel"
|
|
70
|
+
frame={(message) => (
|
|
71
|
+
// Only the name comes back with the message: the actions and the footer may be what threw.
|
|
72
|
+
<Frame title={props.title} chevron={props.chevron} className={props.className} broken>
|
|
73
|
+
{message}
|
|
74
|
+
</Frame>
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
<Frame {...props} />
|
|
78
|
+
</ErrorBoundary>
|
|
79
|
+
)
|
|
80
|
+
}
|
package/src/PersonTrigger.tsx
CHANGED
|
@@ -60,7 +60,7 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
|
|
|
60
60
|
)
|
|
61
61
|
}
|
|
62
62
|
return (
|
|
63
|
-
// @estiva-escape: N5 (Katerina, 24 September: record only): a row that fills on hover, as NavItem also does
|
|
63
|
+
// @estiva-escape(no-copied-look): N5 (Katerina, 24 September: record only): a row that fills on hover, as NavItem also does
|
|
64
64
|
<BaseButton
|
|
65
65
|
type="button"
|
|
66
66
|
aria-haspopup="menu"
|
package/src/Reaction.tsx
CHANGED
|
@@ -63,7 +63,7 @@ export interface ReactionProps extends Omit<ButtonHTMLAttributes<HTMLButtonEleme
|
|
|
63
63
|
|
|
64
64
|
export function Reaction({ emoji, count, pressed = false, className, ...props }: ReactionProps) {
|
|
65
65
|
return (
|
|
66
|
-
// @estiva-escape: W1 (Katerina, 24 September: leave alone): a reaction pill shares four words with a field's box by coincidence
|
|
66
|
+
// @estiva-escape(no-copied-look): W1 (Katerina, 24 September: leave alone): a reaction pill shares four words with a field's box by coincidence
|
|
67
67
|
<Toggle
|
|
68
68
|
// Controlled: a reaction is yours when the data says so, not when the
|
|
69
69
|
// pill was last pressed — a write can fail, and another client can
|
package/src/ScrollArea.mdx
CHANGED
|
@@ -6,8 +6,8 @@ import * as ScrollAreaStories from './ScrollArea.stories'
|
|
|
6
6
|
# ScrollArea
|
|
7
7
|
|
|
8
8
|
A region that scrolls without taking width for its scrollbar. The bar is
|
|
9
|
-
drawn over the content and shows
|
|
10
|
-
|
|
9
|
+
drawn over the content and shows whenever there is more to scroll to, so a
|
|
10
|
+
long list says so before anyone hovers it; nothing moves when it appears.
|
|
11
11
|
|
|
12
12
|
<Canvas of={ScrollAreaStories.Default} />
|
|
13
13
|
|
|
@@ -27,6 +27,10 @@ the content is moving; nothing moves when it appears.
|
|
|
27
27
|
- A box that never overflows. A ScrollArea around it changes nothing, and
|
|
28
28
|
is one more element.
|
|
29
29
|
- The page itself. The browser owns that bar.
|
|
30
|
+
- A whole list column → **ListColumn**, which scrolls its rows itself.
|
|
31
|
+
|
|
32
|
+
No alternative for the first two: a box that never overflows needs nothing
|
|
33
|
+
around it, and the page's bar is the browser's.
|
|
30
34
|
|
|
31
35
|
## How
|
|
32
36
|
|
|
@@ -15,7 +15,7 @@ type Story = StoryObj<typeof meta>
|
|
|
15
15
|
|
|
16
16
|
const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`)
|
|
17
17
|
|
|
18
|
-
/** A list taller than its box. The bar shows
|
|
18
|
+
/** A list taller than its box. The bar shows at rest, because there is more to scroll to. */
|
|
19
19
|
export const Default: Story = {
|
|
20
20
|
render: () => (
|
|
21
21
|
<Card className="h-[240px] w-[280px]">
|
package/src/ScrollArea.tsx
CHANGED
|
@@ -10,8 +10,13 @@ import { cn } from './cn'
|
|
|
10
10
|
* right-hand padding looks wider than the left. Katerina saw it in every
|
|
11
11
|
* scrolling surface of both apps (2026-09-08). Base UI's `ScrollArea` hides
|
|
12
12
|
* the native bar and draws its own over the content, so nothing moves when
|
|
13
|
-
* it appears
|
|
14
|
-
*
|
|
13
|
+
* it appears.
|
|
14
|
+
*
|
|
15
|
+
* The bar shows whenever the region has more to scroll to, at rest too
|
|
16
|
+
* (Katerina, 26 September: a long folder list in Peek's side column looked
|
|
17
|
+
* complete, because the bar waited for the pointer). Base UI writes
|
|
18
|
+
* `data-has-overflow-y` / `-x` on the bar while that axis overflows; a region
|
|
19
|
+
* with nothing more to show keeps no bar at all.
|
|
15
20
|
*
|
|
16
21
|
* Three boxes, and each class prop lands on one:
|
|
17
22
|
*
|
|
@@ -99,12 +104,12 @@ export function ScrollArea({ orientation = 'vertical', className, viewportClassN
|
|
|
99
104
|
</BaseScrollArea.Content>
|
|
100
105
|
</BaseScrollArea.Viewport>
|
|
101
106
|
{vertical && (
|
|
102
|
-
<BaseScrollArea.Scrollbar orientation="vertical" className={cn(BAR, 'w-1.5 justify-center py-0.5 pr-0.5')}>
|
|
107
|
+
<BaseScrollArea.Scrollbar orientation="vertical" className={cn(BAR, 'w-1.5 justify-center py-0.5 pr-0.5 data-[has-overflow-y]:opacity-100')}>
|
|
103
108
|
<BaseScrollArea.Thumb className={cn(THUMB, 'w-full')} />
|
|
104
109
|
</BaseScrollArea.Scrollbar>
|
|
105
110
|
)}
|
|
106
111
|
{horizontal && (
|
|
107
|
-
<BaseScrollArea.Scrollbar orientation="horizontal" className={cn(BAR, 'h-1.5 flex-col justify-center px-0.5 pb-0.5')}>
|
|
112
|
+
<BaseScrollArea.Scrollbar orientation="horizontal" className={cn(BAR, 'h-1.5 flex-col justify-center px-0.5 pb-0.5 data-[has-overflow-x]:opacity-100')}>
|
|
108
113
|
<BaseScrollArea.Thumb className={cn(THUMB, 'h-full')} />
|
|
109
114
|
</BaseScrollArea.Scrollbar>
|
|
110
115
|
)}
|
package/src/Select.tsx
CHANGED
|
@@ -74,7 +74,7 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
|
|
|
74
74
|
// a native `disabled` button, which Tab skips and a tooltip cannot open on.
|
|
75
75
|
const held = Boolean(disabledReason)
|
|
76
76
|
const trigger = (
|
|
77
|
-
// @estiva-escape: N1 (Katerina, 24 September: record only): Select's box drops the hover while held, so it cannot take FIELD_SHELL_CLASSES as it is
|
|
77
|
+
// @estiva-escape(no-copied-look): N1 (Katerina, 24 September: record only): Select's box drops the hover while held, so it cannot take FIELD_SHELL_CLASSES as it is
|
|
78
78
|
<BaseSelect.Trigger
|
|
79
79
|
aria-label={ariaLabel}
|
|
80
80
|
aria-required={aria['aria-required']}
|
package/src/TopBar.mdx
CHANGED
|
@@ -33,7 +33,7 @@ reachable.
|
|
|
33
33
|
## When not
|
|
34
34
|
|
|
35
35
|
- Page-level headers inside the content → not this; this is the frame's
|
|
36
|
-
bar, once per app.
|
|
36
|
+
bar, once per app. The top of a column is **ContainerHeader**.
|
|
37
37
|
- Anything the bar would need to know — who you are, what is searched —
|
|
38
38
|
stays outside: pass composed slots.
|
|
39
39
|
|
package/src/eslint/escape.ts
CHANGED
|
@@ -4,15 +4,21 @@ import type { AST, Rule } from 'eslint'
|
|
|
4
4
|
* The escape marker (UIG-3, seam S4 in docs/GATES.md §16): one written reason
|
|
5
5
|
* why one element stays as it is.
|
|
6
6
|
*
|
|
7
|
-
* A line comment directly above the element,
|
|
7
|
+
* A line comment directly above the element, naming the rule it keeps off,
|
|
8
8
|
*
|
|
9
|
-
* // @estiva-escape: <reason>
|
|
9
|
+
* // @estiva-escape(no-raw-element): <reason>
|
|
10
10
|
*
|
|
11
11
|
* or, where the element is a JSX child, the JSX comment on the line above,
|
|
12
12
|
*
|
|
13
|
-
* {/* @estiva-escape: <reason> *\/}
|
|
13
|
+
* {/* @estiva-escape(no-raw-element): <reason> *\/}
|
|
14
14
|
*
|
|
15
|
-
* with at least ten characters of reason, spaces not counted.
|
|
15
|
+
* with at least ten characters of reason, spaces not counted. It escapes only
|
|
16
|
+
* the rules it names (Katerina's ruling B3, 25 September: one reason used to
|
|
17
|
+
* switch off every rule on the element, so a raw button that also wrote a
|
|
18
|
+
* dialog's role by hand passed under one reason). Two rules on one element are
|
|
19
|
+
* named together, `@estiva-escape(no-raw-element, no-rebuilt-behaviour)`, each
|
|
20
|
+
* on purpose. A marker that names no rule escapes nothing, and says which name
|
|
21
|
+
* to write.
|
|
16
22
|
*
|
|
17
23
|
* Not `eslint-disable`. A directive switches a rule off without saying it was
|
|
18
24
|
* meant, and the count of escapes (`.gates-count.json`) could not see it. A
|
|
@@ -37,11 +43,29 @@ export const SETTINGS_KEY = 'estiva'
|
|
|
37
43
|
* reports through the rule that called it.
|
|
38
44
|
*/
|
|
39
45
|
export const ESCAPE_MESSAGES = {
|
|
40
|
-
escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}: <why this stays>\`. An escape with no reason hides nothing.`,
|
|
41
|
-
escapeInDirective: `Write the escape as its own comment on the line above: \`// ${ESCAPE_MARKER}: <reason>\`. Inside an eslint-disable comment it switches the rule off instead of recording why.`,
|
|
46
|
+
escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}({{rule}}): <why this stays>\`. An escape with no reason hides nothing.`,
|
|
47
|
+
escapeInDirective: `Write the escape as its own comment on the line above: \`// ${ESCAPE_MARKER}({{rule}}): <reason>\`. Inside an eslint-disable comment it switches the rule off instead of recording why.`,
|
|
48
|
+
escapeWithoutRule: `Name the rule this escape keeps off: \`${ESCAPE_MARKER}({{rule}}): <reason>\`. An escape now keeps off only the rules it names.`,
|
|
42
49
|
escaped: 'Escaped: {{reason}}',
|
|
43
50
|
} as const
|
|
44
51
|
|
|
52
|
+
/** A rule's name as a marker writes it: `no-raw-element` for `estiva/no-raw-element`. */
|
|
53
|
+
export const shortRuleName = (id: string) => id.slice(id.lastIndexOf('/') + 1)
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What a marker says, read from a comment's text: the rules it names (none when
|
|
57
|
+
* it names none) and its reason. Null when the comment carries no marker.
|
|
58
|
+
*/
|
|
59
|
+
export function readMarker(text: string): { rules: string[]; reason: string } | null {
|
|
60
|
+
const at = text.indexOf(ESCAPE_MARKER)
|
|
61
|
+
if (at === -1) return null
|
|
62
|
+
const rest = text.slice(at + ESCAPE_MARKER.length)
|
|
63
|
+
const named = /^\s*\(([^)]*)\)/.exec(rest)
|
|
64
|
+
const rules = named ? named[1].split(/[\s,]+/).filter(Boolean).map(shortRuleName) : []
|
|
65
|
+
const reason = (named ? rest.slice(named[0].length) : rest).replace(/^\s*:/, '').trim()
|
|
66
|
+
return { rules, reason }
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
export interface EstivaSettings {
|
|
46
70
|
/**
|
|
47
71
|
* Report every sanctioned escape as a message with the id `escaped`, so a
|
|
@@ -85,24 +109,29 @@ export function isEscaped(context: Rule.RuleContext, node: Located): boolean {
|
|
|
85
109
|
.find((c) => c.loc && c.range && c.loc.end.line === line - 1 && BETWEEN.test(sourceCode.text.slice(c.range[1], nodeStart)))
|
|
86
110
|
if (!comment?.loc) return false
|
|
87
111
|
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
112
|
+
const marker = readMarker(comment.value)
|
|
113
|
+
if (!marker) return false
|
|
114
|
+
const rule = shortRuleName(context.id)
|
|
90
115
|
|
|
91
116
|
const directive = DIRECTIVE.exec(comment.value)
|
|
92
117
|
if (directive) {
|
|
93
118
|
const rules = directiveRules(directive[1])
|
|
94
119
|
// A directive for other rules (the token lint's notes) is not an escape of this one.
|
|
95
120
|
if (rules.length > 0 && !rules.includes(context.id)) return false
|
|
96
|
-
context.report({ loc: comment.loc, messageId: 'escapeInDirective' })
|
|
121
|
+
context.report({ loc: comment.loc, messageId: 'escapeInDirective', data: { rule } })
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Named, but not this rule: another rule on the element may be the one it keeps off.
|
|
126
|
+
if (marker.rules.length > 0 && !marker.rules.includes(rule)) return false
|
|
127
|
+
if (marker.rules.length === 0) {
|
|
128
|
+
context.report({ loc: comment.loc, messageId: 'escapeWithoutRule', data: { rule } })
|
|
97
129
|
return false
|
|
98
130
|
}
|
|
99
131
|
|
|
100
|
-
const reason =
|
|
101
|
-
.slice(at + ESCAPE_MARKER.length)
|
|
102
|
-
.replace(/^:/, '')
|
|
103
|
-
.trim()
|
|
132
|
+
const { reason } = marker
|
|
104
133
|
if (reason.replace(/\s/g, '').length < MIN_REASON) {
|
|
105
|
-
context.report({ loc: comment.loc, messageId: 'escapeWithoutReason' })
|
|
134
|
+
context.report({ loc: comment.loc, messageId: 'escapeWithoutReason', data: { rule } })
|
|
106
135
|
return false
|
|
107
136
|
}
|
|
108
137
|
|