@adea-ai/ui 0.95.1 → 0.95.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/components/layout/sidebar-nav/sidebar-nav.d.ts +1 -1
- package/dist/components/layout/sidebar-nav/sidebar-nav.js +6 -6
- package/dist/components/layout/sidebar-nav/sidebar-nav.js.map +1 -1
- package/dist/lint/index.d.ts +7 -0
- package/dist/lint/index.d.ts.map +1 -1
- package/dist/lint.js +253 -62
- package/dist/lint.js.map +1 -1
- package/dist/r/src/components/layout/sidebar-nav/sidebar-nav.tsx +6 -6
- package/package.json +1 -1
- package/src/components/layout/sidebar-nav/sidebar-nav.tsx +6 -6
- package/src/lint/index.ts +316 -55
|
@@ -196,7 +196,7 @@ export function SidebarNavSection(props: SidebarNavSectionProps) {
|
|
|
196
196
|
data-active={local.active ? '' : undefined}
|
|
197
197
|
class={cn(
|
|
198
198
|
'group/section-header flex items-center gap-1 rounded-md px-2 py-1.5',
|
|
199
|
-
{ '[@media(pointer:coarse)]:py-0': local.collapsible },
|
|
199
|
+
{ '[@media(any-pointer:coarse)]:py-0': local.collapsible },
|
|
200
200
|
local.active ? 'bg-primary-subtle text-foreground' : 'hover:bg-sidebar-accent/60'
|
|
201
201
|
)}
|
|
202
202
|
>
|
|
@@ -228,7 +228,7 @@ export function SidebarNavSection(props: SidebarNavSectionProps) {
|
|
|
228
228
|
trigger.onReorder?.(event.key === 'ArrowUp' ? 'up' : 'down')
|
|
229
229
|
}}
|
|
230
230
|
class={cn(
|
|
231
|
-
'flex min-w-0 flex-1 items-center gap-1.5 rounded-sm text-2xs font-medium tracking-wide uppercase outline-none transition-colors ease-out hover:text-sidebar-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle [@media(pointer:coarse)]:min-h-11',
|
|
231
|
+
'flex min-w-0 flex-1 items-center gap-1.5 rounded-sm text-2xs font-medium tracking-wide uppercase outline-none transition-colors ease-out hover:text-sidebar-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle [@media(any-pointer:coarse)]:min-h-11',
|
|
232
232
|
local.active ? 'text-foreground' : 'text-sidebar-muted-foreground'
|
|
233
233
|
)}
|
|
234
234
|
>
|
|
@@ -237,7 +237,7 @@ export function SidebarNavSection(props: SidebarNavSectionProps) {
|
|
|
237
237
|
</Show>
|
|
238
238
|
</Polymorphic>
|
|
239
239
|
<Show when={local.action}>
|
|
240
|
-
<span class="shrink-0 opacity-0 transition-opacity ease-out group-hover/section-header:opacity-100 focus-within:opacity-100 [@media(
|
|
240
|
+
<span class="shrink-0 opacity-0 transition-opacity ease-out group-hover/section-header:opacity-100 focus-within:opacity-100 [@media(any-pointer:coarse)]:opacity-100">
|
|
241
241
|
{local.action}
|
|
242
242
|
</span>
|
|
243
243
|
</Show>
|
|
@@ -275,7 +275,7 @@ export function SidebarNavRow(props: SidebarNavRowProps) {
|
|
|
275
275
|
<Show when={local.actions}>
|
|
276
276
|
<span
|
|
277
277
|
data-slot="sidebar-nav-row-actions"
|
|
278
|
-
class="ms-auto flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity ease-out group-hover/sidebar-nav-row:opacity-100 group-focus-within/sidebar-nav-row:opacity-100 has-[[aria-haspopup=menu][aria-expanded=true]]:opacity-100 has-[[aria-haspopup=true][aria-expanded=true]]:opacity-100 [@media(
|
|
278
|
+
class="ms-auto flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity ease-out group-hover/sidebar-nav-row:opacity-100 group-focus-within/sidebar-nav-row:opacity-100 has-[[aria-haspopup=menu][aria-expanded=true]]:opacity-100 has-[[aria-haspopup=true][aria-expanded=true]]:opacity-100 [@media(any-pointer:coarse)]:opacity-100"
|
|
279
279
|
>
|
|
280
280
|
{local.actions}
|
|
281
281
|
</span>
|
|
@@ -310,11 +310,11 @@ export type SidebarNavItemProps<T extends ValidComponent = 'a'> = PolymorphicPro
|
|
|
310
310
|
|
|
311
311
|
/**
|
|
312
312
|
* A sidebar row. Exported classes, so a caller's own element can match it.
|
|
313
|
-
*
|
|
313
|
+
* Any coarse pointer gets a 44px target without changing desktop density.
|
|
314
314
|
*/
|
|
315
315
|
export const sidebarNavItemClass = [
|
|
316
316
|
'group/nav-item relative flex min-w-0 items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium',
|
|
317
|
-
'whitespace-nowrap transition-colors ease-out outline-none select-none [@media(pointer:coarse)]:min-h-11',
|
|
317
|
+
'whitespace-nowrap transition-colors ease-out outline-none select-none [@media(any-pointer:coarse)]:min-h-11',
|
|
318
318
|
'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle',
|
|
319
319
|
'[&_svg]:size-4 [&_svg]:shrink-0',
|
|
320
320
|
].join(' ')
|
package/package.json
CHANGED
|
@@ -196,7 +196,7 @@ export function SidebarNavSection(props: SidebarNavSectionProps) {
|
|
|
196
196
|
data-active={local.active ? '' : undefined}
|
|
197
197
|
class={cn(
|
|
198
198
|
'group/section-header flex items-center gap-1 rounded-md px-2 py-1.5',
|
|
199
|
-
{ '[@media(pointer:coarse)]:py-0': local.collapsible },
|
|
199
|
+
{ '[@media(any-pointer:coarse)]:py-0': local.collapsible },
|
|
200
200
|
local.active ? 'bg-primary-subtle text-foreground' : 'hover:bg-sidebar-accent/60'
|
|
201
201
|
)}
|
|
202
202
|
>
|
|
@@ -228,7 +228,7 @@ export function SidebarNavSection(props: SidebarNavSectionProps) {
|
|
|
228
228
|
trigger.onReorder?.(event.key === 'ArrowUp' ? 'up' : 'down')
|
|
229
229
|
}}
|
|
230
230
|
class={cn(
|
|
231
|
-
'flex min-w-0 flex-1 items-center gap-1.5 rounded-sm text-2xs font-medium tracking-wide uppercase outline-none transition-colors ease-out hover:text-sidebar-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle [@media(pointer:coarse)]:min-h-11',
|
|
231
|
+
'flex min-w-0 flex-1 items-center gap-1.5 rounded-sm text-2xs font-medium tracking-wide uppercase outline-none transition-colors ease-out hover:text-sidebar-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle [@media(any-pointer:coarse)]:min-h-11',
|
|
232
232
|
local.active ? 'text-foreground' : 'text-sidebar-muted-foreground'
|
|
233
233
|
)}
|
|
234
234
|
>
|
|
@@ -237,7 +237,7 @@ export function SidebarNavSection(props: SidebarNavSectionProps) {
|
|
|
237
237
|
</Show>
|
|
238
238
|
</Polymorphic>
|
|
239
239
|
<Show when={local.action}>
|
|
240
|
-
<span class="shrink-0 opacity-0 transition-opacity ease-out group-hover/section-header:opacity-100 focus-within:opacity-100 [@media(
|
|
240
|
+
<span class="shrink-0 opacity-0 transition-opacity ease-out group-hover/section-header:opacity-100 focus-within:opacity-100 [@media(any-pointer:coarse)]:opacity-100">
|
|
241
241
|
{local.action}
|
|
242
242
|
</span>
|
|
243
243
|
</Show>
|
|
@@ -275,7 +275,7 @@ export function SidebarNavRow(props: SidebarNavRowProps) {
|
|
|
275
275
|
<Show when={local.actions}>
|
|
276
276
|
<span
|
|
277
277
|
data-slot="sidebar-nav-row-actions"
|
|
278
|
-
class="ms-auto flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity ease-out group-hover/sidebar-nav-row:opacity-100 group-focus-within/sidebar-nav-row:opacity-100 has-[[aria-haspopup=menu][aria-expanded=true]]:opacity-100 has-[[aria-haspopup=true][aria-expanded=true]]:opacity-100 [@media(
|
|
278
|
+
class="ms-auto flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity ease-out group-hover/sidebar-nav-row:opacity-100 group-focus-within/sidebar-nav-row:opacity-100 has-[[aria-haspopup=menu][aria-expanded=true]]:opacity-100 has-[[aria-haspopup=true][aria-expanded=true]]:opacity-100 [@media(any-pointer:coarse)]:opacity-100"
|
|
279
279
|
>
|
|
280
280
|
{local.actions}
|
|
281
281
|
</span>
|
|
@@ -310,11 +310,11 @@ export type SidebarNavItemProps<T extends ValidComponent = 'a'> = PolymorphicPro
|
|
|
310
310
|
|
|
311
311
|
/**
|
|
312
312
|
* A sidebar row. Exported classes, so a caller's own element can match it.
|
|
313
|
-
*
|
|
313
|
+
* Any coarse pointer gets a 44px target without changing desktop density.
|
|
314
314
|
*/
|
|
315
315
|
export const sidebarNavItemClass = [
|
|
316
316
|
'group/nav-item relative flex min-w-0 items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium',
|
|
317
|
-
'whitespace-nowrap transition-colors ease-out outline-none select-none [@media(pointer:coarse)]:min-h-11',
|
|
317
|
+
'whitespace-nowrap transition-colors ease-out outline-none select-none [@media(any-pointer:coarse)]:min-h-11',
|
|
318
318
|
'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle',
|
|
319
319
|
'[&_svg]:size-4 [&_svg]:shrink-0',
|
|
320
320
|
].join(' ')
|
package/src/lint/index.ts
CHANGED
|
@@ -42,6 +42,9 @@ type RuleModule = {
|
|
|
42
42
|
|
|
43
43
|
interface LintContext {
|
|
44
44
|
options?: unknown[]
|
|
45
|
+
sourceCode?: {
|
|
46
|
+
getScope?: (node: unknown) => LintScope | undefined
|
|
47
|
+
}
|
|
45
48
|
report: (descriptor: {
|
|
46
49
|
node: unknown
|
|
47
50
|
messageId?: string
|
|
@@ -50,6 +53,25 @@ interface LintContext {
|
|
|
50
53
|
}) => void
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
interface LintScope {
|
|
57
|
+
set?: Map<string, any>
|
|
58
|
+
upper?: LintScope
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface LintVariable {
|
|
62
|
+
defs?: { type?: string; parent?: { kind?: string }; node?: { init?: unknown } }[]
|
|
63
|
+
identifiers?: object[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const variableOf = (node: any, context: LintContext): LintVariable | null => {
|
|
67
|
+
if (node?.type !== 'Identifier' && node?.type !== 'JSXIdentifier') return null
|
|
68
|
+
for (let scope = context.sourceCode?.getScope?.(node); scope; scope = scope.upper) {
|
|
69
|
+
const variable = scope.set?.get(node.name)
|
|
70
|
+
if (variable) return variable
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
/** Reads a static object-property key without evaluating a computed expression. */
|
|
54
76
|
const staticPropertyKey = (node: any, computed: boolean): string | null => {
|
|
55
77
|
if (!computed && node?.type === 'Identifier') return node.name
|
|
@@ -177,7 +199,7 @@ const noRawInteractiveElements: RuleModule = {
|
|
|
177
199
|
type: 'problem',
|
|
178
200
|
docs: {
|
|
179
201
|
description:
|
|
180
|
-
'Interactive elements this package provides (button, input, textarea, select, option, label) are composed from the package, not written as raw markup.',
|
|
202
|
+
'Interactive elements this package provides (button, input, textarea, select, option, label), including statically known Solid Dynamic tags, are composed from the package, not written as raw markup.',
|
|
181
203
|
},
|
|
182
204
|
schema: [
|
|
183
205
|
{
|
|
@@ -197,6 +219,10 @@ const noRawInteractiveElements: RuleModule = {
|
|
|
197
219
|
create(context) {
|
|
198
220
|
const options = (context.options?.[0] ?? {}) as RawElementOptions
|
|
199
221
|
const allowed = new Set(options.allow ?? [])
|
|
222
|
+
const dynamicImports: SolidDynamicImports = {
|
|
223
|
+
named: new WeakSet(),
|
|
224
|
+
namespaces: new WeakSet(),
|
|
225
|
+
}
|
|
200
226
|
const emit = (node: unknown, element: string) => {
|
|
201
227
|
const target = ELEMENT_PRIMITIVES[element]!
|
|
202
228
|
context.report({
|
|
@@ -207,11 +233,14 @@ const noRawInteractiveElements: RuleModule = {
|
|
|
207
233
|
})
|
|
208
234
|
}
|
|
209
235
|
return {
|
|
236
|
+
Program(node: any) {
|
|
237
|
+
collectSolidDynamicImports(node, dynamicImports)
|
|
238
|
+
},
|
|
210
239
|
JSXOpeningElement(node: any) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
240
|
+
for (const element of jsxElementNames(node, dynamicImports, context)) {
|
|
241
|
+
if (!(element in ELEMENT_PRIMITIVES) || allowed.has(element)) continue
|
|
242
|
+
emit(node, element)
|
|
243
|
+
}
|
|
215
244
|
},
|
|
216
245
|
}
|
|
217
246
|
},
|
|
@@ -238,20 +267,37 @@ const DEFAULT_PRIMITIVE_PACKAGES = Object.freeze([
|
|
|
238
267
|
|
|
239
268
|
/**
|
|
240
269
|
* The interactive ARIA roles an application can dress a generic element in, and
|
|
241
|
-
* the primitive that owns each role.
|
|
242
|
-
*
|
|
243
|
-
* case.
|
|
270
|
+
* the published primitive that owns each role. Native anchors are not generic
|
|
271
|
+
* elements, so their built-in link semantics are unaffected by the `link` entry.
|
|
244
272
|
*/
|
|
245
273
|
const ROLE_PRIMITIVES: Readonly<Record<string, { primitive: string; path: string }>> =
|
|
246
274
|
Object.freeze({
|
|
247
275
|
button: { primitive: 'Button', path: '@adea-ai/ui/components/ui/button' },
|
|
276
|
+
link: { primitive: 'TextLink', path: '@adea-ai/ui/components/ui/text-link' },
|
|
248
277
|
checkbox: { primitive: 'Checkbox', path: '@adea-ai/ui/components/ui/checkbox' },
|
|
249
|
-
radio: { primitive: '
|
|
278
|
+
radio: { primitive: 'RadioGroupItem', path: '@adea-ai/ui/components/ui/radio-group' },
|
|
279
|
+
radiogroup: { primitive: 'RadioGroup', path: '@adea-ai/ui/components/ui/radio-group' },
|
|
250
280
|
switch: { primitive: 'Switch', path: '@adea-ai/ui/components/ui/switch' },
|
|
251
|
-
tab: { primitive: '
|
|
252
|
-
|
|
253
|
-
|
|
281
|
+
tab: { primitive: 'TabsTrigger', path: '@adea-ai/ui/components/ui/tabs' },
|
|
282
|
+
tablist: { primitive: 'TabsList', path: '@adea-ai/ui/components/ui/tabs' },
|
|
283
|
+
tabpanel: { primitive: 'TabsContent', path: '@adea-ai/ui/components/ui/tabs' },
|
|
284
|
+
option: { primitive: 'SelectItem', path: '@adea-ai/ui/components/ui/select' },
|
|
285
|
+
listbox: { primitive: 'Select', path: '@adea-ai/ui/components/ui/select' },
|
|
286
|
+
combobox: { primitive: 'Combobox', path: '@adea-ai/ui/components/ui/combobox' },
|
|
287
|
+
searchbox: { primitive: 'Input', path: '@adea-ai/ui/components/ui/input' },
|
|
288
|
+
menu: { primitive: 'DropdownMenuContent', path: '@adea-ai/ui/components/ui/dropdown-menu' },
|
|
289
|
+
menuitem: { primitive: 'DropdownMenuItem', path: '@adea-ai/ui/components/ui/dropdown-menu' },
|
|
290
|
+
menuitemcheckbox: {
|
|
291
|
+
primitive: 'DropdownMenuCheckboxItem',
|
|
292
|
+
path: '@adea-ai/ui/components/ui/dropdown-menu',
|
|
293
|
+
},
|
|
294
|
+
menuitemradio: {
|
|
295
|
+
primitive: 'DropdownMenuRadioItem',
|
|
296
|
+
path: '@adea-ai/ui/components/ui/dropdown-menu',
|
|
297
|
+
},
|
|
254
298
|
textbox: { primitive: 'Input', path: '@adea-ai/ui/components/ui/input' },
|
|
299
|
+
tree: { primitive: 'Tree', path: '@adea-ai/ui/components/composites/tree' },
|
|
300
|
+
treeitem: { primitive: 'TreeRow', path: '@adea-ai/ui/components/composites/tree' },
|
|
255
301
|
})
|
|
256
302
|
|
|
257
303
|
/**
|
|
@@ -284,44 +330,206 @@ const GENERIC_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
284
330
|
'svg',
|
|
285
331
|
])
|
|
286
332
|
|
|
287
|
-
const CLICK_HANDLERS: ReadonlySet<string> = new Set(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
333
|
+
const CLICK_HANDLERS: ReadonlySet<string> = new Set(
|
|
334
|
+
[
|
|
335
|
+
'onClick',
|
|
336
|
+
'onDblClick',
|
|
337
|
+
'onDoubleClick',
|
|
338
|
+
'onPointerDown',
|
|
339
|
+
'onPointerUp',
|
|
340
|
+
'onMouseDown',
|
|
341
|
+
'onMouseUp',
|
|
342
|
+
'onTouchStart',
|
|
343
|
+
'onTouchEnd',
|
|
344
|
+
'on:click',
|
|
345
|
+
'on:dblclick',
|
|
346
|
+
'on:pointerdown',
|
|
347
|
+
'on:pointerup',
|
|
348
|
+
'on:mousedown',
|
|
349
|
+
'on:mouseup',
|
|
350
|
+
'on:touchstart',
|
|
351
|
+
'on:touchend',
|
|
352
|
+
].flatMap((handler) => [handler, handler.toLowerCase()])
|
|
353
|
+
)
|
|
301
354
|
|
|
302
355
|
interface InteractiveWrapperOptions {
|
|
303
356
|
/** Element names this rule should not report (rare; prefer fixing the file). */
|
|
304
357
|
allow?: string[]
|
|
358
|
+
/** Also reject opaque generic-element props and Solid Dynamic tag overrides. */
|
|
359
|
+
rejectUnknownSpreads?: boolean
|
|
305
360
|
/** Replaces the default report message. */
|
|
306
361
|
message?: string
|
|
307
362
|
}
|
|
308
363
|
|
|
309
|
-
/**
|
|
310
|
-
const
|
|
364
|
+
/** Returns a const binding's immutable initializer, when its value is statically knowable. */
|
|
365
|
+
const constInitializer = (node: any, context: LintContext): any => {
|
|
366
|
+
const variable = variableOf(node, context)
|
|
367
|
+
const definition = variable?.defs?.[0]
|
|
368
|
+
if (definition?.type !== 'Variable' || definition.parent?.kind !== 'const') return null
|
|
369
|
+
return definition.node?.init ?? null
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Reads literal strings through static branches and local const aliases. */
|
|
373
|
+
const staticStrings = (node: any, context?: LintContext, seen = new Set<unknown>()): string[] => {
|
|
374
|
+
if (!node || seen.has(node)) return []
|
|
375
|
+
seen.add(node)
|
|
376
|
+
if (
|
|
377
|
+
node.type === 'TSAsExpression' ||
|
|
378
|
+
node.type === 'TSSatisfiesExpression' ||
|
|
379
|
+
node.type === 'TSNonNullExpression'
|
|
380
|
+
)
|
|
381
|
+
return staticStrings(node.expression, context, seen)
|
|
311
382
|
if (node?.type === 'Literal' && typeof node.value === 'string') return [node.value]
|
|
312
|
-
if (node?.type === 'JSXExpressionContainer') return staticStrings(node.expression)
|
|
383
|
+
if (node?.type === 'JSXExpressionContainer') return staticStrings(node.expression, context, seen)
|
|
313
384
|
if (node?.type === 'ConditionalExpression')
|
|
314
|
-
return [
|
|
385
|
+
return [
|
|
386
|
+
...staticStrings(node.consequent, context, new Set(seen)),
|
|
387
|
+
...staticStrings(node.alternate, context, new Set(seen)),
|
|
388
|
+
]
|
|
389
|
+
if (node?.type === 'LogicalExpression')
|
|
390
|
+
return [
|
|
391
|
+
...staticStrings(node.left, context, new Set(seen)),
|
|
392
|
+
...staticStrings(node.right, context, new Set(seen)),
|
|
393
|
+
]
|
|
315
394
|
if (node?.type === 'TemplateLiteral' && node.expressions.length === 0)
|
|
316
395
|
return [node.quasis[0].value.cooked ?? node.quasis[0].value.raw]
|
|
396
|
+
if (node?.type === 'Identifier' && context) {
|
|
397
|
+
const initializer = constInitializer(node, context)
|
|
398
|
+
return initializer ? staticStrings(initializer, context, seen) : []
|
|
399
|
+
}
|
|
317
400
|
return []
|
|
318
401
|
}
|
|
319
402
|
|
|
320
|
-
|
|
403
|
+
interface SolidDynamicImports {
|
|
404
|
+
named: WeakSet<object>
|
|
405
|
+
namespaces: WeakSet<object>
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const collectSolidDynamicImport = (node: any, imports: SolidDynamicImports): void => {
|
|
409
|
+
if (node.source?.value !== 'solid-js/web') return
|
|
410
|
+
for (const specifier of node.specifiers ?? []) {
|
|
411
|
+
if (specifier.type === 'ImportNamespaceSpecifier' && specifier.local)
|
|
412
|
+
imports.namespaces.add(specifier.local)
|
|
413
|
+
if (specifier.type !== 'ImportSpecifier') continue
|
|
414
|
+
const imported = specifier.imported?.name ?? specifier.imported?.value
|
|
415
|
+
if (imported === 'Dynamic' && specifier.local) imports.named.add(specifier.local)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const collectSolidDynamicImports = (program: any, imports: SolidDynamicImports): void => {
|
|
420
|
+
for (const statement of program.body ?? [])
|
|
421
|
+
if (statement.type === 'ImportDeclaration') collectSolidDynamicImport(statement, imports)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const isImportedBinding = (node: any, bindings: WeakSet<object>, context: LintContext): boolean =>
|
|
425
|
+
variableOf(node, context)?.identifiers?.some((identifier: object) => bindings.has(identifier)) ??
|
|
426
|
+
false
|
|
427
|
+
|
|
428
|
+
const isSolidDynamic = (name: any, imports: SolidDynamicImports, context: LintContext): boolean => {
|
|
429
|
+
if (name?.type === 'JSXIdentifier') return isImportedBinding(name, imports.named, context)
|
|
430
|
+
return (
|
|
431
|
+
name?.type === 'JSXMemberExpression' &&
|
|
432
|
+
name.property?.name === 'Dynamic' &&
|
|
433
|
+
name.object?.type === 'JSXIdentifier' &&
|
|
434
|
+
isImportedBinding(name.object, imports.namespaces, context)
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
interface DynamicComponentResolution {
|
|
439
|
+
values: any[]
|
|
440
|
+
opaqueAfterKnownIntrinsic: boolean
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const valuesIncludeKnownIntrinsic = (values: any[], context: LintContext): boolean =>
|
|
444
|
+
values
|
|
445
|
+
.flatMap((value) => staticStrings(value, context))
|
|
446
|
+
.some((element) => GENERIC_ELEMENTS.has(element) || element in ELEMENT_PRIMITIVES)
|
|
447
|
+
|
|
448
|
+
/** Applies `component` assignments made by a spread, tracking opaque overrides. */
|
|
449
|
+
const applyDynamicComponentSpread = (
|
|
450
|
+
node: any,
|
|
451
|
+
resolution: DynamicComponentResolution,
|
|
452
|
+
context: LintContext
|
|
453
|
+
): DynamicComponentResolution => {
|
|
454
|
+
while (
|
|
455
|
+
node?.type === 'TSAsExpression' ||
|
|
456
|
+
node?.type === 'TSSatisfiesExpression' ||
|
|
457
|
+
node?.type === 'TSNonNullExpression'
|
|
458
|
+
)
|
|
459
|
+
node = node.expression
|
|
460
|
+
if (!node || node.type !== 'ObjectExpression')
|
|
461
|
+
return {
|
|
462
|
+
values: [],
|
|
463
|
+
opaqueAfterKnownIntrinsic:
|
|
464
|
+
resolution.opaqueAfterKnownIntrinsic ||
|
|
465
|
+
valuesIncludeKnownIntrinsic(resolution.values, context),
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let current = resolution
|
|
469
|
+
for (const property of node.properties ?? []) {
|
|
470
|
+
if (property.type === 'SpreadElement') {
|
|
471
|
+
current = applyDynamicComponentSpread(property.argument, current, context)
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
if (property.type !== 'Property') continue
|
|
475
|
+
const key = staticPropertyKey(property.key, property.computed === true)
|
|
476
|
+
if (key === 'component')
|
|
477
|
+
current = { values: [property.value], opaqueAfterKnownIntrinsic: false }
|
|
478
|
+
else if (property.computed && key === null)
|
|
479
|
+
current = {
|
|
480
|
+
values: [],
|
|
481
|
+
opaqueAfterKnownIntrinsic:
|
|
482
|
+
current.opaqueAfterKnownIntrinsic || valuesIncludeKnownIntrinsic(current.values, context),
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return current
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const dynamicComponentResolution = (
|
|
489
|
+
node: any,
|
|
490
|
+
context: LintContext
|
|
491
|
+
): DynamicComponentResolution => {
|
|
492
|
+
let resolution: DynamicComponentResolution = {
|
|
493
|
+
values: [],
|
|
494
|
+
opaqueAfterKnownIntrinsic: false,
|
|
495
|
+
}
|
|
496
|
+
for (const attribute of node.attributes ?? []) {
|
|
497
|
+
if (attribute.type === 'JSXAttribute' && attribute.name?.name === 'component') {
|
|
498
|
+
resolution = {
|
|
499
|
+
values: attribute.value ? [attribute.value] : [],
|
|
500
|
+
opaqueAfterKnownIntrinsic: false,
|
|
501
|
+
}
|
|
502
|
+
continue
|
|
503
|
+
}
|
|
504
|
+
if (attribute.type === 'JSXSpreadAttribute')
|
|
505
|
+
resolution = applyDynamicComponentSpread(attribute.argument, resolution, context)
|
|
506
|
+
}
|
|
507
|
+
return resolution
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Resolves native tags passed to Solid Dynamic while leaving component values alone. */
|
|
511
|
+
const jsxElementNames = (
|
|
512
|
+
node: any,
|
|
513
|
+
imports: SolidDynamicImports,
|
|
514
|
+
context: LintContext
|
|
515
|
+
): string[] => {
|
|
516
|
+
if (isSolidDynamic(node.name, imports, context))
|
|
517
|
+
return [
|
|
518
|
+
...new Set(
|
|
519
|
+
dynamicComponentResolution(node, context).values.flatMap((value) =>
|
|
520
|
+
staticStrings(value, context)
|
|
521
|
+
)
|
|
522
|
+
),
|
|
523
|
+
]
|
|
524
|
+
if (node.name?.type === 'JSXIdentifier') return [node.name.name]
|
|
525
|
+
return []
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const roleOf = (node: any, context: LintContext): string | null => {
|
|
321
529
|
for (const attribute of node.attributes ?? []) {
|
|
322
530
|
if (attribute.type === 'JSXSpreadAttribute') {
|
|
323
531
|
const role = staticObjectPropertyValues(attribute.argument, 'role')
|
|
324
|
-
.flatMap((value) => staticStrings(value))
|
|
532
|
+
.flatMap((value) => staticStrings(value, context))
|
|
325
533
|
.flatMap((value) => value.split(/\s+/))
|
|
326
534
|
.find((value) => value in ROLE_PRIMITIVES)
|
|
327
535
|
if (role) return role
|
|
@@ -329,7 +537,7 @@ const roleOf = (node: any): string | null => {
|
|
|
329
537
|
}
|
|
330
538
|
if (attribute.type !== 'JSXAttribute' || attribute.name?.name !== 'role') continue
|
|
331
539
|
return (
|
|
332
|
-
staticStrings(attribute.value)
|
|
540
|
+
staticStrings(attribute.value, context)
|
|
333
541
|
.flatMap((role) => role.split(/\s+/))
|
|
334
542
|
.find((role) => role in ROLE_PRIMITIVES) ?? null
|
|
335
543
|
)
|
|
@@ -374,6 +582,28 @@ const hasTabStop = (node: any): boolean =>
|
|
|
374
582
|
return false
|
|
375
583
|
})
|
|
376
584
|
|
|
585
|
+
/** Unknown/computed props make a JSX object spread opaque to interaction checks. */
|
|
586
|
+
const opaqueSpread = (node: any, seen = new Set<unknown>()): boolean => {
|
|
587
|
+
while (
|
|
588
|
+
node?.type === 'TSAsExpression' ||
|
|
589
|
+
node?.type === 'TSSatisfiesExpression' ||
|
|
590
|
+
node?.type === 'TSNonNullExpression'
|
|
591
|
+
)
|
|
592
|
+
node = node.expression
|
|
593
|
+
if (!node || seen.has(node) || node.type !== 'ObjectExpression') return true
|
|
594
|
+
seen.add(node)
|
|
595
|
+
return node.properties.some((property: any) => {
|
|
596
|
+
if (property.type === 'SpreadElement') return opaqueSpread(property.argument, seen)
|
|
597
|
+
if (property.type !== 'Property') return true
|
|
598
|
+
return staticPropertyKey(property.key, property.computed === true) === null
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const hasOpaqueSpread = (node: any): boolean =>
|
|
603
|
+
(node.attributes ?? []).some(
|
|
604
|
+
(attribute: any) => attribute.type === 'JSXSpreadAttribute' && opaqueSpread(attribute.argument)
|
|
605
|
+
)
|
|
606
|
+
|
|
377
607
|
const noInteractiveWrappers: RuleModule = {
|
|
378
608
|
meta: {
|
|
379
609
|
type: 'problem',
|
|
@@ -386,6 +616,7 @@ const noInteractiveWrappers: RuleModule = {
|
|
|
386
616
|
type: 'object',
|
|
387
617
|
properties: {
|
|
388
618
|
allow: { type: 'array', items: { type: 'string' } },
|
|
619
|
+
rejectUnknownSpreads: { type: 'boolean' },
|
|
389
620
|
message: { type: 'string' },
|
|
390
621
|
},
|
|
391
622
|
additionalProperties: false,
|
|
@@ -398,34 +629,65 @@ const noInteractiveWrappers: RuleModule = {
|
|
|
398
629
|
'A click handler on {{element}} composes {{primitive}} by hand: use {{primitive}} from {{path}}. A div that reacts to clicks is a button that cannot be reached by keyboard.',
|
|
399
630
|
tabindexWrapper:
|
|
400
631
|
'A tabindex on {{element}} makes a layout element focusable, which is focus management the primitives already own: compose the real component instead.',
|
|
632
|
+
opaqueSpreadWrapper:
|
|
633
|
+
'A spread on {{element}} may hide a role, activation handler, or tab stop. Pass statically named props or compose the shared component instead.',
|
|
634
|
+
opaqueDynamicComponentWrapper:
|
|
635
|
+
'An opaque spread follows a known raw design-system or generic tag on Solid Dynamic and may replace the tag or hide interactive props. Pass statically named props or a shared component so the interaction rule can inspect the final component.',
|
|
401
636
|
},
|
|
402
637
|
},
|
|
403
638
|
create(context) {
|
|
404
639
|
const options = (context.options?.[0] ?? {}) as InteractiveWrapperOptions
|
|
405
640
|
const allowed = new Set(options.allow ?? [])
|
|
641
|
+
const dynamicImports: SolidDynamicImports = {
|
|
642
|
+
named: new WeakSet(),
|
|
643
|
+
namespaces: new WeakSet(),
|
|
644
|
+
}
|
|
406
645
|
return {
|
|
646
|
+
Program(node: any) {
|
|
647
|
+
collectSolidDynamicImports(node, dynamicImports)
|
|
648
|
+
},
|
|
407
649
|
JSXOpeningElement(node: any) {
|
|
408
|
-
if (node.name
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const target = ROLE_PRIMITIVES[role]!
|
|
419
|
-
context.report({
|
|
420
|
-
node,
|
|
421
|
-
messageId: 'roleWrapper',
|
|
422
|
-
data: { element, role, primitive: target.primitive, path: target.path },
|
|
423
|
-
...(options.message ? { message: options.message } : {}),
|
|
424
|
-
})
|
|
425
|
-
return
|
|
650
|
+
if (options.rejectUnknownSpreads && isSolidDynamic(node.name, dynamicImports, context)) {
|
|
651
|
+
const resolution = dynamicComponentResolution(node, context)
|
|
652
|
+
if (resolution.opaqueAfterKnownIntrinsic) {
|
|
653
|
+
context.report({
|
|
654
|
+
node,
|
|
655
|
+
messageId: 'opaqueDynamicComponentWrapper',
|
|
656
|
+
...(options.message ? { message: options.message } : {}),
|
|
657
|
+
})
|
|
658
|
+
return
|
|
659
|
+
}
|
|
426
660
|
}
|
|
427
661
|
|
|
428
|
-
|
|
662
|
+
for (const element of jsxElementNames(node, dynamicImports, context)) {
|
|
663
|
+
if (allowed.has(element) || element in ELEMENT_PRIMITIVES) continue
|
|
664
|
+
// Capitalized JSX components are composed controls, not raw elements.
|
|
665
|
+
// Solid Dynamic is resolved above only when its component is a known tag.
|
|
666
|
+
if (element[0] === element[0]?.toUpperCase()) continue
|
|
667
|
+
if (!GENERIC_ELEMENTS.has(element)) continue
|
|
668
|
+
|
|
669
|
+
if (options.rejectUnknownSpreads && hasOpaqueSpread(node)) {
|
|
670
|
+
context.report({
|
|
671
|
+
node,
|
|
672
|
+
messageId: 'opaqueSpreadWrapper',
|
|
673
|
+
data: { element },
|
|
674
|
+
...(options.message ? { message: options.message } : {}),
|
|
675
|
+
})
|
|
676
|
+
break
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const role = roleOf(node, context)
|
|
680
|
+
if (role && role in ROLE_PRIMITIVES) {
|
|
681
|
+
const target = ROLE_PRIMITIVES[role]!
|
|
682
|
+
context.report({
|
|
683
|
+
node,
|
|
684
|
+
messageId: 'roleWrapper',
|
|
685
|
+
data: { element, role, primitive: target.primitive, path: target.path },
|
|
686
|
+
...(options.message ? { message: options.message } : {}),
|
|
687
|
+
})
|
|
688
|
+
continue
|
|
689
|
+
}
|
|
690
|
+
|
|
429
691
|
const click = [...CLICK_HANDLERS].find((handler) => has(node, handler))
|
|
430
692
|
if (click) {
|
|
431
693
|
const target = ROLE_PRIMITIVES.button!
|
|
@@ -435,16 +697,15 @@ const noInteractiveWrappers: RuleModule = {
|
|
|
435
697
|
data: { element, primitive: target.primitive, path: target.path },
|
|
436
698
|
...(options.message ? { message: options.message } : {}),
|
|
437
699
|
})
|
|
438
|
-
|
|
700
|
+
continue
|
|
439
701
|
}
|
|
440
|
-
if (hasTabStop(node))
|
|
702
|
+
if (hasTabStop(node))
|
|
441
703
|
context.report({
|
|
442
704
|
node,
|
|
443
705
|
messageId: 'tabindexWrapper',
|
|
444
706
|
data: { element },
|
|
445
707
|
...(options.message ? { message: options.message } : {}),
|
|
446
708
|
})
|
|
447
|
-
}
|
|
448
709
|
}
|
|
449
710
|
},
|
|
450
711
|
}
|