@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.
@@ -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(hover:none)]:opacity-100">
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(hover:none)]:opacity-100"
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
- * Coarse pointers get a 44px target without changing Kiro's desktop density.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.95.1",
3
+ "version": "0.95.3",
4
4
  "description": "The Adea design system: Kobalte-backed Solid components, semantic OKLCH tokens, and the app-shell layout primitives shared by Adea and Cortana.",
5
5
  "keywords": [
6
6
  "adea",
@@ -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(hover:none)]:opacity-100">
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(hover:none)]:opacity-100"
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
- * Coarse pointers get a 44px target without changing Kiro's desktop density.
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
- if (node.name?.type !== 'JSXIdentifier') return
212
- const element = node.name.name
213
- if (!(element in ELEMENT_PRIMITIVES) || allowed.has(element)) return
214
- emit(node, element)
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. `link` is deliberately absent: an `<a>` is
242
- * natively interactive and Button itself supports `as="a"` for the link-styled
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: 'RadioGroup', path: '@adea-ai/ui/components/ui/radio-group' },
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: 'Tabs', path: '@adea-ai/ui/components/ui/tabs' },
252
- option: { primitive: 'Select', path: '@adea-ai/ui/components/ui/select' },
253
- menuitem: { primitive: 'DropdownMenu', path: '@adea-ai/ui/components/ui/dropdown-menu' },
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
- 'onClick',
289
- 'onDoubleClick',
290
- 'onPointerDown',
291
- 'onPointerUp',
292
- 'onMouseDown',
293
- 'onMouseUp',
294
- 'on:click',
295
- 'on:dblclick',
296
- 'on:pointerdown',
297
- 'on:pointerup',
298
- 'on:mousedown',
299
- 'on:mouseup',
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
- /** Only statically knowable branches; unresolved roles remain a review boundary. */
310
- const staticStrings = (node: any): string[] => {
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 [...staticStrings(node.consequent), ...staticStrings(node.alternate)]
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
- const roleOf = (node: any): string | null => {
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?.type !== 'JSXIdentifier') return
409
- const element = node.name.name
410
- if (allowed.has(element) || element in ELEMENT_PRIMITIVES) return
411
- // A capitalized element is a component — often a design-system primitive
412
- // composing a role deliberately (a Button carrying role="option" inside a
413
- // custom listbox, for example). That is composition, not impersonation.
414
- if (element[0] === element[0]?.toUpperCase()) return
415
-
416
- const role = roleOf(node)
417
- if (role && role in ROLE_PRIMITIVES) {
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
- if (GENERIC_ELEMENTS.has(element)) {
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
- return
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
  }