@adea-ai/ui 0.107.0 → 0.108.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/components/layout/contextual-sidebar/contextual-sidebar.d.ts +55 -0
- package/dist/components/layout/contextual-sidebar/contextual-sidebar.d.ts.map +1 -0
- package/dist/components/layout/contextual-sidebar/contextual-sidebar.js +199 -0
- package/dist/components/layout/contextual-sidebar/contextual-sidebar.js.map +1 -0
- package/dist/components/layout/contextual-sidebar/index.d.ts +3 -0
- package/dist/components/layout/contextual-sidebar/index.d.ts.map +1 -0
- package/dist/components/layout/contextual-sidebar/index.js +3 -0
- package/dist/components/layout/pixel-resize-handle/index.d.ts +2 -0
- package/dist/components/layout/pixel-resize-handle/index.d.ts.map +1 -0
- package/dist/components/layout/pixel-resize-handle/index.js +2 -0
- package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.d.ts +29 -0
- package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.d.ts.map +1 -0
- package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.js +183 -0
- package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.js.map +1 -0
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.d.ts +3 -19
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.d.ts.map +1 -1
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.js +8 -76
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.js.map +1 -1
- package/dist/components/ui/resizable/resizable.d.ts.map +1 -1
- package/dist/components/ui/resizable/resizable.js +2 -2
- package/dist/components/ui/resizable/resizable.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/lint/index.d.ts.map +1 -1
- package/dist/lint.js +154 -20
- package/dist/lint.js.map +1 -1
- package/dist/r/contextual-sidebar.json +25 -0
- package/dist/r/pixel-resize-handle.json +23 -0
- package/dist/r/registry.json +49 -1
- package/dist/r/sidebar-nav.json +1 -1
- package/dist/r/src/components/layout/contextual-sidebar/contextual-sidebar.tsx +180 -0
- package/dist/r/src/components/layout/contextual-sidebar/index.ts +6 -0
- package/dist/r/src/components/layout/pixel-resize-handle/index.ts +1 -0
- package/dist/r/src/components/layout/pixel-resize-handle/pixel-resize-handle.tsx +177 -0
- package/dist/r/src/components/layout/sidebar-nav/sidebar-nav-resize-handle.tsx +7 -55
- package/dist/r/src/components/ui/resizable/resizable.tsx +4 -2
- package/package.json +3 -1
- package/registry.json +49 -1
- package/src/components/layout/contextual-sidebar/contextual-sidebar.tsx +180 -0
- package/src/components/layout/contextual-sidebar/index.ts +6 -0
- package/src/components/layout/pixel-resize-handle/index.ts +1 -0
- package/src/components/layout/pixel-resize-handle/pixel-resize-handle.tsx +177 -0
- package/src/components/layout/sidebar-nav/sidebar-nav-resize-handle.tsx +7 -55
- package/src/components/ui/resizable/resizable.tsx +4 -2
- package/src/index.ts +1 -0
- package/src/lint/index.ts +234 -22
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../../ui/resizable'
|
|
2
|
+
import { createSignal, onCleanup } from 'solid-js'
|
|
3
|
+
import { cn } from '#lib/utils'
|
|
4
|
+
|
|
5
|
+
export type PixelResizeHandleProps = {
|
|
6
|
+
/** Which physical edge of the ruler the resizable surface is attached to. */
|
|
7
|
+
side: 'left' | 'right'
|
|
8
|
+
/** Current width in CSS pixels. */
|
|
9
|
+
value: number
|
|
10
|
+
minimum: number
|
|
11
|
+
maximum: number
|
|
12
|
+
step?: number
|
|
13
|
+
label?: string
|
|
14
|
+
controls?: string
|
|
15
|
+
class?: string
|
|
16
|
+
onChange(value: number): void
|
|
17
|
+
onCommit?(value: number): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* PixelResizeHandle.
|
|
22
|
+
*
|
|
23
|
+
* A keyboard- and pointer-operable edge ruler for a host-owned pane. Keep the
|
|
24
|
+
* pane's actual width and any persistence in its layout owner; this component
|
|
25
|
+
* reports a bounded pixel value and places the shared resizable grip at the
|
|
26
|
+
* matching left or right edge.
|
|
27
|
+
*
|
|
28
|
+
* Corvu 0.2.5 supplies geometry, keyboard resizing, ARIA and the shared grip,
|
|
29
|
+
* but its global drag handler does not end on pointer cancellation. Prevent
|
|
30
|
+
* that handler through its public start hook and use local pointer capture
|
|
31
|
+
* for bounded pixel gestures, including cancellation and lost capture.
|
|
32
|
+
*/
|
|
33
|
+
export function PixelResizeHandle(props: PixelResizeHandleProps) {
|
|
34
|
+
const maximum = () => Math.max(1, props.maximum)
|
|
35
|
+
const minimum = () => Math.min(maximum(), Math.max(0, props.minimum))
|
|
36
|
+
const value = () => Math.min(maximum(), Math.max(minimum(), Math.round(props.value)))
|
|
37
|
+
const ratio = () => value() / maximum()
|
|
38
|
+
const isRight = () => props.side === 'right'
|
|
39
|
+
const rulerSizes = () => (isRight() ? [1 - ratio(), ratio()] : [ratio(), 1 - ratio()])
|
|
40
|
+
const commit = () => props.onCommit?.(value())
|
|
41
|
+
let interacting = false
|
|
42
|
+
const [drag, setDrag] = createSignal<{
|
|
43
|
+
pointerId: number
|
|
44
|
+
startX: number
|
|
45
|
+
startValue: number
|
|
46
|
+
handle: HTMLElement
|
|
47
|
+
}>()
|
|
48
|
+
const endPointer = (shouldCommit: boolean) => {
|
|
49
|
+
const current = drag()
|
|
50
|
+
if (!current) return
|
|
51
|
+
setDrag(undefined)
|
|
52
|
+
if (current.handle.hasPointerCapture(current.pointerId))
|
|
53
|
+
current.handle.releasePointerCapture(current.pointerId)
|
|
54
|
+
if (shouldCommit) commit()
|
|
55
|
+
}
|
|
56
|
+
const endKeyboard = () => {
|
|
57
|
+
const wasInteracting = interacting
|
|
58
|
+
interacting = false
|
|
59
|
+
if (wasInteracting) commit()
|
|
60
|
+
}
|
|
61
|
+
onCleanup(() => {
|
|
62
|
+
endKeyboard()
|
|
63
|
+
endPointer(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ResizablePanelGroup
|
|
68
|
+
orientation="horizontal"
|
|
69
|
+
dir="ltr"
|
|
70
|
+
sizes={rulerSizes()}
|
|
71
|
+
keyboardDelta={`${props.step ?? 16}px`}
|
|
72
|
+
style={{ width: `${maximum()}px` }}
|
|
73
|
+
class={cn(
|
|
74
|
+
'pointer-events-none absolute inset-y-0 flex',
|
|
75
|
+
{
|
|
76
|
+
'left-0': props.side === 'left',
|
|
77
|
+
'right-0': props.side === 'right',
|
|
78
|
+
},
|
|
79
|
+
props.class
|
|
80
|
+
)}
|
|
81
|
+
onSizesChange={(sizes) => {
|
|
82
|
+
// Panel registration and teardown also emit sizes. Only user resizing
|
|
83
|
+
// may update the host preference; collapsing must preserve its width.
|
|
84
|
+
if (!interacting || sizes.length !== 2) return
|
|
85
|
+
const next = Math.round((sizes[isRight() ? 1 : 0] ?? ratio()) * maximum())
|
|
86
|
+
const bounded = Math.min(maximum(), Math.max(minimum(), next))
|
|
87
|
+
if (bounded !== value()) props.onChange(bounded)
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<ResizablePanel
|
|
91
|
+
minSize={isRight() ? 0 : minimum() / maximum()}
|
|
92
|
+
maxSize={1}
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
/>
|
|
95
|
+
<ResizableHandle
|
|
96
|
+
withHandle
|
|
97
|
+
label={props.label ?? `Resize ${props.side} pane`}
|
|
98
|
+
aria-controls={props.controls}
|
|
99
|
+
aria-valuemin={minimum()}
|
|
100
|
+
aria-valuemax={maximum()}
|
|
101
|
+
aria-valuenow={value()}
|
|
102
|
+
aria-valuetext={`${value()} pixels`}
|
|
103
|
+
data-dragging={drag() ? '' : undefined}
|
|
104
|
+
onHandleDragStart={(event) => {
|
|
105
|
+
// Corvu checks defaultPrevented before starting its global handler.
|
|
106
|
+
event.preventDefault()
|
|
107
|
+
if (event.button !== 0 || !event.isPrimary) return
|
|
108
|
+
const handle = event.currentTarget
|
|
109
|
+
if (!(handle instanceof HTMLElement)) return
|
|
110
|
+
endPointer(false)
|
|
111
|
+
interacting = false
|
|
112
|
+
handle.focus()
|
|
113
|
+
handle.setPointerCapture(event.pointerId)
|
|
114
|
+
setDrag({
|
|
115
|
+
pointerId: event.pointerId,
|
|
116
|
+
startX: event.clientX,
|
|
117
|
+
startValue: value(),
|
|
118
|
+
handle,
|
|
119
|
+
})
|
|
120
|
+
}}
|
|
121
|
+
onPointerMove={(event: PointerEvent) => {
|
|
122
|
+
const current = drag()
|
|
123
|
+
if (!current || event.pointerId !== current.pointerId) return
|
|
124
|
+
if (!current.handle.hasPointerCapture(current.pointerId)) {
|
|
125
|
+
endPointer(false)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
const delta = (event.clientX - current.startX) * (isRight() ? -1 : 1)
|
|
129
|
+
const next = Math.min(
|
|
130
|
+
maximum(),
|
|
131
|
+
Math.max(minimum(), Math.round(current.startValue + delta))
|
|
132
|
+
)
|
|
133
|
+
if (next !== value()) props.onChange(next)
|
|
134
|
+
}}
|
|
135
|
+
onPointerUp={(event: PointerEvent) => {
|
|
136
|
+
if (event.pointerId === drag()?.pointerId) endPointer(true)
|
|
137
|
+
}}
|
|
138
|
+
onPointerCancel={(event: PointerEvent) => {
|
|
139
|
+
if (event.pointerId === drag()?.pointerId) endPointer(false)
|
|
140
|
+
}}
|
|
141
|
+
onLostPointerCapture={(event: PointerEvent) => {
|
|
142
|
+
if (event.pointerId === drag()?.pointerId) endPointer(false)
|
|
143
|
+
}}
|
|
144
|
+
onBlur={() => {
|
|
145
|
+
endKeyboard()
|
|
146
|
+
endPointer(false)
|
|
147
|
+
}}
|
|
148
|
+
onKeyDown={(event) => {
|
|
149
|
+
const current = drag()
|
|
150
|
+
if (current && !current.handle.hasPointerCapture(current.pointerId)) endPointer(false)
|
|
151
|
+
if (drag()) {
|
|
152
|
+
event.preventDefault()
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) interacting = true
|
|
156
|
+
// Corvu operates on physical panel order. Home/End here describe
|
|
157
|
+
// the controlled pane width, including a pane after the separator.
|
|
158
|
+
if (event.key === 'Home' || event.key === 'End') {
|
|
159
|
+
event.preventDefault()
|
|
160
|
+
props.onChange(event.key === 'Home' ? minimum() : maximum())
|
|
161
|
+
}
|
|
162
|
+
}}
|
|
163
|
+
onKeyUp={(event) => {
|
|
164
|
+
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
|
|
165
|
+
endKeyboard()
|
|
166
|
+
}
|
|
167
|
+
}}
|
|
168
|
+
class="pointer-events-auto"
|
|
169
|
+
/>
|
|
170
|
+
<ResizablePanel
|
|
171
|
+
minSize={isRight() ? minimum() / maximum() : 0}
|
|
172
|
+
maxSize={1}
|
|
173
|
+
aria-hidden="true"
|
|
174
|
+
/>
|
|
175
|
+
</ResizablePanelGroup>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -1,59 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
PixelResizeHandle,
|
|
3
|
+
type PixelResizeHandleProps,
|
|
4
|
+
} from '../pixel-resize-handle/pixel-resize-handle'
|
|
3
5
|
|
|
4
|
-
export type SidebarNavResizeHandleProps =
|
|
5
|
-
value: number
|
|
6
|
-
minimum: number
|
|
7
|
-
maximum: number
|
|
8
|
-
step?: number
|
|
9
|
-
label?: string
|
|
10
|
-
controls?: string
|
|
11
|
-
class?: string
|
|
12
|
-
onChange(value: number): void
|
|
13
|
-
onCommit?(value: number): void
|
|
14
|
-
}
|
|
6
|
+
export type SidebarNavResizeHandleProps = Omit<PixelResizeHandleProps, 'side'>
|
|
15
7
|
|
|
16
|
-
/**
|
|
17
|
-
* Resize a contextual sidebar without making its host recreate a separator
|
|
18
|
-
* widget. Corvu owns pointer capture, cancellation, constraints, keyboard
|
|
19
|
-
* movement and cleanup. Its two invisible panels provide a bounded pixel
|
|
20
|
-
* ruler; the host retains the real pane layout and preference persistence.
|
|
21
|
-
* Place this inside the positioned sidebar. The ruler can extend over the
|
|
22
|
-
* adjacent content, but only its handle participates in pointer hit testing.
|
|
23
|
-
*/
|
|
8
|
+
/** Keep the established left-sidebar API as a thin adapter over the shared edge ruler. */
|
|
24
9
|
export function SidebarNavResizeHandle(props: SidebarNavResizeHandleProps) {
|
|
25
|
-
|
|
26
|
-
const minimum = () => Math.min(maximum(), Math.max(0, props.minimum))
|
|
27
|
-
const value = () => Math.min(maximum(), Math.max(minimum(), props.value))
|
|
28
|
-
const commit = () => props.onCommit?.(value())
|
|
29
|
-
|
|
30
|
-
return (
|
|
31
|
-
<Resizable
|
|
32
|
-
orientation="horizontal"
|
|
33
|
-
sizes={[value() / maximum(), 1 - value() / maximum()]}
|
|
34
|
-
keyboardDelta={`${props.step ?? 16}px`}
|
|
35
|
-
style={{ width: `${maximum()}px` }}
|
|
36
|
-
class={cn('pointer-events-none absolute inset-y-0 start-0 flex', props.class)}
|
|
37
|
-
onSizesChange={(sizes) => {
|
|
38
|
-
const next = Math.round((sizes[0] ?? 0) * maximum())
|
|
39
|
-
if (next !== value()) props.onChange(Math.min(maximum(), Math.max(minimum(), next)))
|
|
40
|
-
}}
|
|
41
|
-
>
|
|
42
|
-
<Resizable.Panel minSize={minimum() / maximum()} maxSize={1} aria-hidden="true" />
|
|
43
|
-
<Resizable.Handle
|
|
44
|
-
aria-label={props.label ?? 'Resize navigation'}
|
|
45
|
-
aria-controls={props.controls}
|
|
46
|
-
aria-orientation="vertical"
|
|
47
|
-
aria-valuemin={minimum()}
|
|
48
|
-
aria-valuemax={maximum()}
|
|
49
|
-
aria-valuenow={value()}
|
|
50
|
-
onHandleDragEnd={commit}
|
|
51
|
-
onKeyUp={(event) => {
|
|
52
|
-
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) commit()
|
|
53
|
-
}}
|
|
54
|
-
class="pointer-events-auto relative z-(--z-docked) w-px -translate-x-full shrink-0 bg-transparent after:absolute after:inset-y-0 after:-inset-x-1 after:w-3 hover:bg-primary focus-visible:bg-primary focus-visible:ring-3 focus-visible:ring-primary-subtle focus-visible:outline-none"
|
|
55
|
-
/>
|
|
56
|
-
<Resizable.Panel minSize={0} aria-hidden="true" />
|
|
57
|
-
</Resizable>
|
|
58
|
-
)
|
|
10
|
+
return <PixelResizeHandle {...props} side="left" label={props.label ?? 'Resize navigation'} />
|
|
59
11
|
}
|
|
@@ -17,7 +17,7 @@ import { cn } from '#lib/utils'
|
|
|
17
17
|
* adjusted from the keyboard; a bare `cursor-col-resize` div cannot.
|
|
18
18
|
*/
|
|
19
19
|
/**
|
|
20
|
-
* Whether the group was given `initialSizes`. Those are corvu's to apply, and a
|
|
20
|
+
* Whether the group was given `initialSizes` or controlled `sizes`. Those are corvu's to apply, and a
|
|
21
21
|
* panel's own `initialSize` overrides them — so an unsized panel must not invent
|
|
22
22
|
* one when the group already has an answer.
|
|
23
23
|
*/
|
|
@@ -27,7 +27,9 @@ export function ResizablePanelGroup(props: ComponentProps<typeof Resizable> & {
|
|
|
27
27
|
const [local, rest] = splitProps(props, ['class'])
|
|
28
28
|
|
|
29
29
|
return (
|
|
30
|
-
<GroupSizesContext.Provider
|
|
30
|
+
<GroupSizesContext.Provider
|
|
31
|
+
value={() => rest.initialSizes !== undefined || rest.sizes !== undefined}
|
|
32
|
+
>
|
|
31
33
|
<Resizable
|
|
32
34
|
class={cn('flex size-full data-[orientation=vertical]:flex-col', local.class)}
|
|
33
35
|
{...rest}
|
package/src/index.ts
CHANGED
|
@@ -89,6 +89,7 @@ export * from './components/layout/page'
|
|
|
89
89
|
export * from './components/layout/panel'
|
|
90
90
|
export * from './components/layout/side-rail'
|
|
91
91
|
export * from './components/layout/sidebar-nav'
|
|
92
|
+
export * from './components/layout/contextual-sidebar'
|
|
92
93
|
export * from './components/layout/status-bar'
|
|
93
94
|
export * from './components/layout/top-bar'
|
|
94
95
|
|
package/src/lint/index.ts
CHANGED
|
@@ -747,6 +747,185 @@ const staticStringValue = (node: any): string | null => {
|
|
|
747
747
|
return null
|
|
748
748
|
}
|
|
749
749
|
|
|
750
|
+
interface ImportedJsxBindings {
|
|
751
|
+
named: WeakSet<object>
|
|
752
|
+
namespaces: WeakSet<object>
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const isImportedJsxComponent = (
|
|
756
|
+
name: any,
|
|
757
|
+
imports: ImportedJsxBindings,
|
|
758
|
+
context: LintContext,
|
|
759
|
+
namespaceExport?: string
|
|
760
|
+
): boolean => {
|
|
761
|
+
if (name?.type === 'JSXIdentifier' || name?.type === 'Identifier')
|
|
762
|
+
return isImportedBinding(name, imports.named, context)
|
|
763
|
+
const memberName = name?.property?.name
|
|
764
|
+
return (
|
|
765
|
+
(name?.type === 'JSXMemberExpression' || name?.type === 'MemberExpression') &&
|
|
766
|
+
(name.object?.type === 'JSXIdentifier' || name.object?.type === 'Identifier') &&
|
|
767
|
+
(name.property?.type === 'JSXIdentifier' || name.property?.type === 'Identifier') &&
|
|
768
|
+
(namespaceExport ? memberName === namespaceExport : /^[A-Z]/.test(memberName ?? '')) &&
|
|
769
|
+
isImportedBinding(name.object, imports.namespaces, context)
|
|
770
|
+
)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
type IconChildKind = 'empty' | 'icon' | 'text' | 'unknown'
|
|
774
|
+
|
|
775
|
+
const CONTENT_OVERRIDE_PROPS = new Set([
|
|
776
|
+
'children',
|
|
777
|
+
'dangerouslySetInnerHTML',
|
|
778
|
+
'innerHTML',
|
|
779
|
+
'textContent',
|
|
780
|
+
])
|
|
781
|
+
|
|
782
|
+
/** Opaque spreads and objects containing content props may override children. */
|
|
783
|
+
const spreadMayOverrideContent = (argument: any): boolean => {
|
|
784
|
+
while (
|
|
785
|
+
argument?.type === 'TSAsExpression' ||
|
|
786
|
+
argument?.type === 'TSSatisfiesExpression' ||
|
|
787
|
+
argument?.type === 'TSNonNullExpression'
|
|
788
|
+
)
|
|
789
|
+
argument = argument.expression
|
|
790
|
+
if (argument?.type !== 'ObjectExpression') return true
|
|
791
|
+
return argument.properties.some((property: any) => {
|
|
792
|
+
if (property.type !== 'Property') return true
|
|
793
|
+
const key = staticPropertyKey(property.key, property.computed === true)
|
|
794
|
+
return key === null || CONTENT_OVERRIDE_PROPS.has(key)
|
|
795
|
+
})
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const hasContentOverride = (openingElement: any): boolean =>
|
|
799
|
+
(openingElement?.attributes ?? []).some((attribute: any) => {
|
|
800
|
+
if (attribute.type === 'JSXSpreadAttribute') return spreadMayOverrideContent(attribute.argument)
|
|
801
|
+
return (
|
|
802
|
+
attribute.type === 'JSXAttribute' &&
|
|
803
|
+
attribute.name?.type === 'JSXIdentifier' &&
|
|
804
|
+
CONTENT_OVERRIDE_PROPS.has(attribute.name.name)
|
|
805
|
+
)
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
const iconChildrenKind = (
|
|
809
|
+
children: any[],
|
|
810
|
+
icons: ImportedJsxBindings,
|
|
811
|
+
context: LintContext
|
|
812
|
+
): IconChildKind => {
|
|
813
|
+
let hasIcon = false
|
|
814
|
+
for (const child of children) {
|
|
815
|
+
const kind = iconChildKind(child, icons, context)
|
|
816
|
+
if (kind === 'text' || kind === 'unknown') return kind
|
|
817
|
+
if (kind === 'icon') hasIcon = true
|
|
818
|
+
}
|
|
819
|
+
return hasIcon ? 'icon' : 'empty'
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const SVG_TEXT_CONTAINERS = new Set(['text', 'foreignObject'])
|
|
823
|
+
|
|
824
|
+
/** A raw SVG may be labelled or contain dynamic text instead of an icon. */
|
|
825
|
+
const svgMayContainVisibleText = (children: any[]): boolean => {
|
|
826
|
+
for (const child of children) {
|
|
827
|
+
if (!child) return true
|
|
828
|
+
if (child.type === 'JSXText') {
|
|
829
|
+
if (String(child.value ?? '').trim()) return true
|
|
830
|
+
continue
|
|
831
|
+
}
|
|
832
|
+
if (child.type === 'JSXFragment') {
|
|
833
|
+
if (svgMayContainVisibleText(child.children ?? [])) return true
|
|
834
|
+
continue
|
|
835
|
+
}
|
|
836
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
837
|
+
const expression = child.expression
|
|
838
|
+
if (!expression || expression.type === 'JSXEmptyExpression') continue
|
|
839
|
+
if (expression.type === 'JSXElement' || expression.type === 'JSXFragment') {
|
|
840
|
+
if (
|
|
841
|
+
svgMayContainVisibleText(
|
|
842
|
+
expression.type === 'JSXElement' ? [expression] : (expression.children ?? [])
|
|
843
|
+
)
|
|
844
|
+
)
|
|
845
|
+
return true
|
|
846
|
+
continue
|
|
847
|
+
}
|
|
848
|
+
const text = staticStringValue(expression)
|
|
849
|
+
if (text !== null) {
|
|
850
|
+
if (text.trim()) return true
|
|
851
|
+
continue
|
|
852
|
+
}
|
|
853
|
+
if (
|
|
854
|
+
expression.type === 'Literal' &&
|
|
855
|
+
(expression.value === null || typeof expression.value === 'boolean')
|
|
856
|
+
)
|
|
857
|
+
continue
|
|
858
|
+
return true
|
|
859
|
+
}
|
|
860
|
+
if (child.type !== 'JSXElement') return true
|
|
861
|
+
|
|
862
|
+
const name = child.openingElement?.name
|
|
863
|
+
if (name?.type !== 'JSXIdentifier') return true
|
|
864
|
+
if (SVG_TEXT_CONTAINERS.has(name.name)) return true
|
|
865
|
+
if (hasContentOverride(child.openingElement)) return true
|
|
866
|
+
if (name.name === 'title' || name.name === 'desc') continue
|
|
867
|
+
if (!/^[a-z]/.test(name.name) || name.name.includes('-')) return true
|
|
868
|
+
if (svgMayContainVisibleText(child.children ?? [])) return true
|
|
869
|
+
}
|
|
870
|
+
return false
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Proves only the uncomplicated icon-only forms that can be read without
|
|
875
|
+
* executing Solid expressions: imported Lucide components, raw SVG, fragments,
|
|
876
|
+
* whitespace, and spans that contain only those forms.
|
|
877
|
+
*/
|
|
878
|
+
const iconChildKind = (
|
|
879
|
+
child: any,
|
|
880
|
+
icons: ImportedJsxBindings,
|
|
881
|
+
context: LintContext
|
|
882
|
+
): IconChildKind => {
|
|
883
|
+
if (!child) return 'unknown'
|
|
884
|
+
if (child.type === 'JSXText') return String(child.value ?? '').trim() ? 'text' : 'empty'
|
|
885
|
+
if (child.type === 'JSXFragment') return iconChildrenKind(child.children ?? [], icons, context)
|
|
886
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
887
|
+
const expression = child.expression
|
|
888
|
+
if (!expression || expression.type === 'JSXEmptyExpression') return 'empty'
|
|
889
|
+
if (expression.type === 'JSXElement' || expression.type === 'JSXFragment')
|
|
890
|
+
return iconChildKind(expression, icons, context)
|
|
891
|
+
const text = staticStringValue(expression)
|
|
892
|
+
if (text !== null) return text.trim() ? 'text' : 'empty'
|
|
893
|
+
if (
|
|
894
|
+
expression.type === 'Literal' &&
|
|
895
|
+
(expression.value === null || typeof expression.value === 'boolean')
|
|
896
|
+
)
|
|
897
|
+
return 'empty'
|
|
898
|
+
if (expression.type === 'Literal' && typeof expression.value === 'number') return 'text'
|
|
899
|
+
return 'unknown'
|
|
900
|
+
}
|
|
901
|
+
if (child.type !== 'JSXElement') return 'unknown'
|
|
902
|
+
|
|
903
|
+
const name = child.openingElement?.name
|
|
904
|
+
if (name?.type === 'JSXIdentifier' && name.name === 'svg')
|
|
905
|
+
return hasContentOverride(child.openingElement) ||
|
|
906
|
+
svgMayContainVisibleText(child.children ?? [])
|
|
907
|
+
? 'unknown'
|
|
908
|
+
: 'icon'
|
|
909
|
+
if (isImportedJsxComponent(name, icons, context))
|
|
910
|
+
return hasContentOverride(child.openingElement) ? 'unknown' : 'icon'
|
|
911
|
+
if (name?.type === 'JSXIdentifier' && name.name === 'span') {
|
|
912
|
+
if (hasContentOverride(child.openingElement)) return 'unknown'
|
|
913
|
+
return iconChildrenKind(child.children ?? [], icons, context)
|
|
914
|
+
}
|
|
915
|
+
return 'unknown'
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const hasStaticallyIconOnlyChildren = (
|
|
919
|
+
node: any,
|
|
920
|
+
icons: ImportedJsxBindings,
|
|
921
|
+
context: LintContext
|
|
922
|
+
): boolean => {
|
|
923
|
+
return (
|
|
924
|
+
!hasContentOverride(node.openingElement) &&
|
|
925
|
+
iconChildrenKind(node.children ?? [], icons, context) === 'icon'
|
|
926
|
+
)
|
|
927
|
+
}
|
|
928
|
+
|
|
750
929
|
const isIconButtonSize = (node: any): boolean =>
|
|
751
930
|
attributeValues(node, 'size').some((value) => {
|
|
752
931
|
const size = staticStringValue(value)
|
|
@@ -762,64 +941,97 @@ const actionTooltipStatus = (node: any): 'valid' | 'blank' | 'unknown' | 'missin
|
|
|
762
941
|
return 'unknown'
|
|
763
942
|
}
|
|
764
943
|
|
|
765
|
-
const isImportedComponent = (identifier: any, imports: Set<string>): boolean =>
|
|
766
|
-
identifier?.type === 'Identifier' && imports.has(identifier.name)
|
|
767
|
-
|
|
768
944
|
/** Requires shared icon actions to carry the shared explanatory-tooltip contract. */
|
|
769
945
|
const requireActionButtonTooltip: RuleModule = {
|
|
770
946
|
meta: {
|
|
771
947
|
type: 'problem',
|
|
772
948
|
docs: {
|
|
773
949
|
description:
|
|
774
|
-
'
|
|
950
|
+
'Icon-sized shared Buttons and statically proven icon-only Buttons use ActionButton with a tooltip; icon-only ActionButtons need one too.',
|
|
775
951
|
},
|
|
776
952
|
schema: [],
|
|
777
953
|
messages: {
|
|
778
954
|
useActionButton:
|
|
779
|
-
'Icon-size Button actions use ActionButton with a nonblank tooltip. Keep the control name in aria-label; the tooltip explains the action.',
|
|
955
|
+
'Icon-size or icon-only Button actions must use ActionButton with a nonblank tooltip. Keep the control name in aria-label; the tooltip explains the action.',
|
|
780
956
|
missingTooltip:
|
|
781
|
-
'An icon-size ActionButton needs a supplied nonblank tooltip. Keep its accessible name in aria-label as well.',
|
|
957
|
+
'An icon-size or icon-only ActionButton needs a supplied nonblank tooltip. Keep its accessible name in aria-label as well.',
|
|
782
958
|
},
|
|
783
959
|
},
|
|
784
960
|
create(context) {
|
|
785
|
-
const buttons = new
|
|
786
|
-
const actionButtons = new
|
|
961
|
+
const buttons: ImportedJsxBindings = { named: new WeakSet(), namespaces: new WeakSet() }
|
|
962
|
+
const actionButtons: ImportedJsxBindings = { named: new WeakSet(), namespaces: new WeakSet() }
|
|
963
|
+
const icons: ImportedJsxBindings = { named: new WeakSet(), namespaces: new WeakSet() }
|
|
787
964
|
const recordImports = (node: any) => {
|
|
788
965
|
const source = node.source?.value
|
|
789
966
|
const target =
|
|
790
967
|
source === BUTTON_MODULE ? buttons : source === ACTION_BUTTON_MODULE ? actionButtons : null
|
|
968
|
+
if (source === 'lucide-solid') {
|
|
969
|
+
if (node.importKind === 'type') return
|
|
970
|
+
for (const specifier of node.specifiers ?? []) {
|
|
971
|
+
if (!specifier.local || specifier.importKind === 'type') continue
|
|
972
|
+
if (specifier.type === 'ImportNamespaceSpecifier') icons.namespaces.add(specifier.local)
|
|
973
|
+
else if (specifier.type === 'ImportSpecifier') {
|
|
974
|
+
const imported = specifier.imported?.name ?? specifier.imported?.value
|
|
975
|
+
if (typeof imported === 'string' && /^[A-Z]/.test(imported))
|
|
976
|
+
icons.named.add(specifier.local)
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return
|
|
980
|
+
}
|
|
791
981
|
if (!target) return
|
|
982
|
+
if (node.importKind === 'type') return
|
|
792
983
|
for (const specifier of node.specifiers ?? []) {
|
|
793
|
-
if (specifier.type
|
|
984
|
+
if (specifier.type === 'ImportNamespaceSpecifier' && specifier.local) {
|
|
985
|
+
target.namespaces.add(specifier.local)
|
|
986
|
+
continue
|
|
987
|
+
}
|
|
988
|
+
if (
|
|
989
|
+
specifier.type !== 'ImportSpecifier' ||
|
|
990
|
+
!specifier.local ||
|
|
991
|
+
specifier.importKind === 'type'
|
|
992
|
+
)
|
|
993
|
+
continue
|
|
794
994
|
const imported = specifier.imported?.name ?? specifier.imported?.value
|
|
795
995
|
if (
|
|
796
996
|
(source === BUTTON_MODULE && imported === 'Button') ||
|
|
797
997
|
(source === ACTION_BUTTON_MODULE && imported === 'ActionButton')
|
|
798
998
|
)
|
|
799
|
-
target.add(specifier.local
|
|
999
|
+
target.named.add(specifier.local)
|
|
800
1000
|
}
|
|
801
1001
|
}
|
|
802
1002
|
|
|
803
1003
|
return {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
const
|
|
810
|
-
const
|
|
811
|
-
const
|
|
1004
|
+
Program(node: any) {
|
|
1005
|
+
for (const statement of node.body ?? [])
|
|
1006
|
+
if (statement.type === 'ImportDeclaration') recordImports(statement)
|
|
1007
|
+
},
|
|
1008
|
+
JSXElement(node: any) {
|
|
1009
|
+
const opening = node.openingElement
|
|
1010
|
+
const isButton = isImportedJsxComponent(opening.name, buttons, context, 'Button')
|
|
1011
|
+
const isActionButton = isImportedJsxComponent(
|
|
1012
|
+
opening.name,
|
|
1013
|
+
actionButtons,
|
|
1014
|
+
context,
|
|
1015
|
+
'ActionButton'
|
|
1016
|
+
)
|
|
1017
|
+
const asValues = attributeValues(opening, 'as')
|
|
1018
|
+
const targetsButton = asValues.some((value) =>
|
|
1019
|
+
isImportedJsxComponent(value, buttons, context, 'Button')
|
|
1020
|
+
)
|
|
812
1021
|
const targetsActionButton = asValues.some((value) =>
|
|
813
|
-
|
|
1022
|
+
isImportedJsxComponent(value, actionButtons, context, 'ActionButton')
|
|
814
1023
|
)
|
|
1024
|
+
if (!isButton && !isActionButton && !targetsButton && !targetsActionButton) return
|
|
1025
|
+
const iconSized = isIconButtonSize(opening)
|
|
1026
|
+
if (!iconSized && !hasStaticallyIconOnlyChildren(node, icons, context)) return
|
|
815
1027
|
if (isButton || targetsButton) {
|
|
816
|
-
context.report({ node, messageId: 'useActionButton' })
|
|
1028
|
+
context.report({ node: opening, messageId: 'useActionButton' })
|
|
817
1029
|
return
|
|
818
1030
|
}
|
|
819
1031
|
if (!isActionButton && !targetsActionButton) return
|
|
820
|
-
const status = actionTooltipStatus(
|
|
1032
|
+
const status = actionTooltipStatus(opening)
|
|
821
1033
|
if (status === 'missing' || status === 'blank')
|
|
822
|
-
context.report({ node, messageId: 'missingTooltip' })
|
|
1034
|
+
context.report({ node: opening, messageId: 'missingTooltip' })
|
|
823
1035
|
},
|
|
824
1036
|
}
|
|
825
1037
|
},
|