@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.
Files changed (47) hide show
  1. package/dist/components/layout/contextual-sidebar/contextual-sidebar.d.ts +55 -0
  2. package/dist/components/layout/contextual-sidebar/contextual-sidebar.d.ts.map +1 -0
  3. package/dist/components/layout/contextual-sidebar/contextual-sidebar.js +199 -0
  4. package/dist/components/layout/contextual-sidebar/contextual-sidebar.js.map +1 -0
  5. package/dist/components/layout/contextual-sidebar/index.d.ts +3 -0
  6. package/dist/components/layout/contextual-sidebar/index.d.ts.map +1 -0
  7. package/dist/components/layout/contextual-sidebar/index.js +3 -0
  8. package/dist/components/layout/pixel-resize-handle/index.d.ts +2 -0
  9. package/dist/components/layout/pixel-resize-handle/index.d.ts.map +1 -0
  10. package/dist/components/layout/pixel-resize-handle/index.js +2 -0
  11. package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.d.ts +29 -0
  12. package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.d.ts.map +1 -0
  13. package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.js +183 -0
  14. package/dist/components/layout/pixel-resize-handle/pixel-resize-handle.js.map +1 -0
  15. package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.d.ts +3 -19
  16. package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.d.ts.map +1 -1
  17. package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.js +8 -76
  18. package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.js.map +1 -1
  19. package/dist/components/ui/resizable/resizable.d.ts.map +1 -1
  20. package/dist/components/ui/resizable/resizable.js +2 -2
  21. package/dist/components/ui/resizable/resizable.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -1
  25. package/dist/lint/index.d.ts.map +1 -1
  26. package/dist/lint.js +154 -20
  27. package/dist/lint.js.map +1 -1
  28. package/dist/r/contextual-sidebar.json +25 -0
  29. package/dist/r/pixel-resize-handle.json +23 -0
  30. package/dist/r/registry.json +49 -1
  31. package/dist/r/sidebar-nav.json +1 -1
  32. package/dist/r/src/components/layout/contextual-sidebar/contextual-sidebar.tsx +180 -0
  33. package/dist/r/src/components/layout/contextual-sidebar/index.ts +6 -0
  34. package/dist/r/src/components/layout/pixel-resize-handle/index.ts +1 -0
  35. package/dist/r/src/components/layout/pixel-resize-handle/pixel-resize-handle.tsx +177 -0
  36. package/dist/r/src/components/layout/sidebar-nav/sidebar-nav-resize-handle.tsx +7 -55
  37. package/dist/r/src/components/ui/resizable/resizable.tsx +4 -2
  38. package/package.json +3 -1
  39. package/registry.json +49 -1
  40. package/src/components/layout/contextual-sidebar/contextual-sidebar.tsx +180 -0
  41. package/src/components/layout/contextual-sidebar/index.ts +6 -0
  42. package/src/components/layout/pixel-resize-handle/index.ts +1 -0
  43. package/src/components/layout/pixel-resize-handle/pixel-resize-handle.tsx +177 -0
  44. package/src/components/layout/sidebar-nav/sidebar-nav-resize-handle.tsx +7 -55
  45. package/src/components/ui/resizable/resizable.tsx +4 -2
  46. package/src/index.ts +1 -0
  47. 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 Resizable from '@corvu/resizable'
2
- import { cn } from '#lib/utils'
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
- const maximum = () => Math.max(1, props.maximum)
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 value={() => rest.initialSizes !== undefined}>
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
- 'Consumer icon-size shared Button actions use ActionButton with a nonblank tooltip; dynamic values remain a review boundary.',
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 Set<string>()
786
- const actionButtons = new Set<string>()
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 !== 'ImportSpecifier') continue
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?.name)
999
+ target.named.add(specifier.local)
800
1000
  }
801
1001
  }
802
1002
 
803
1003
  return {
804
- ImportDeclaration: recordImports,
805
- JSXOpeningElement(node: any) {
806
- if (!isIconButtonSize(node)) return
807
- const name = node.name?.type === 'JSXIdentifier' ? node.name.name : null
808
- const isButton = name !== null && buttons.has(name)
809
- const isActionButton = name !== null && actionButtons.has(name)
810
- const asValues = attributeValues(node, 'as')
811
- const targetsButton = asValues.some((value) => isImportedComponent(value, buttons))
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
- isImportedComponent(value, actionButtons)
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(node)
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
  },