diff --git a/AGENTS.md b/AGENTS.md index 7b6fedb67..106b24f9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,9 @@ Generate each of these with its command. The formatter and the linter skip them emit a small inline helper and declare it `emulated`. The one exception is a module a Vite plugin registers, whatever the engine: an import under a `passThrough` prefix (`virtual:` by default) reaches every output as written, and that plugin resolves it (§4.4, ADR-0004, - ADR-0064). Its module may import the engine's own runtime, such as `@styleframe/runtime`. + ADR-0064). Its module may import the engine's own runtime, such as `@styleframe/runtime`. A + type import is erased, so an output imports the types the source imports by name, a package's + included (ADR-0067). - **The IR is plain JSON (P5).** Every node carries a span. Add a field only when a consumer reads it. Keep `checkInvariants` true (ADR-0032). diff --git a/docs/adrs/0068-generic-components-and-exported-public-types.md b/docs/adrs/0068-generic-components-and-exported-public-types.md index f8014758e..8450b1a4d 100644 --- a/docs/adrs/0068-generic-components-and-exported-public-types.md +++ b/docs/adrs/0068-generic-components-and-exported-public-types.md @@ -3,7 +3,7 @@ - **Status:** Proposed - **Date:** 2026-10-24 - **Plan:** §4.2, §5.6 (layer 4), §6, §7.2 (L4), §9 M5; G2, P2, P4, P6; ADR-0033, ADR-0034, - ADR-0059, ADR-0067; amends ADR-0034 and ADR-0059 + ADR-0050, ADR-0059, ADR-0067; amends ADR-0034, ADR-0050 and ADR-0059 ## Context @@ -82,8 +82,16 @@ green in its own lane: `types/imported-interface`, `types/extends-chain`, `types `types/exported-types`, `generics/list`, `generics/select`, `generics/scoped-slot`, `generics/constrained`, and `diagnostics/generic-slot-props`. -**What it amends.** ADR-0034 accepts a generic component and a generic props declaration. ADR-0059 -gains the `type` kind and the generic-slot-props gap. +- **A harness rule for the types lanes:** a case whose L3 is quarantined on a target whose browser + build type-checks its output (Angular's) gets the browser stand-in of ADR-0050, so its spec + loads; its tests run, every browser layer fails with the stand-in's cause and is quarantined, + and the summary excuses their scenarios there, until the L3 entry goes. +- A diagnostics case its target has output for (UF4004 is a note) records its browser layers + `skip(no spec: …)` in the compile project: it has no spec, and its server render checks it. + +**What it amends.** ADR-0034 accepts a generic component and a generic props declaration. ADR-0050's +stand-in also serves a case its target cannot compile yet. ADR-0059 gains the `type` kind and the +generic-slot-props gap. ## Consequences diff --git a/packages/analyzer/src/analyze.ts b/packages/analyzer/src/analyze.ts index a77fe6e15..632628299 100644 --- a/packages/analyzer/src/analyze.ts +++ b/packages/analyzer/src/analyze.ts @@ -137,15 +137,16 @@ const RELATIVE_OR_ABSOLUTE = /^(?:[./\\]|[A-Za-z]:)/; export const COMPONENT_SPECIFIER: RegExp = /\.uf\.tsx$/; /** - * The specifiers of the `.uf.tsx` modules a parsed module imports as values, in source order, - * each once: what `compile()` asks its resolver for before the module is analysed. + * The specifiers of the `.uf.tsx` modules a parsed module imports, values or types, in source + * order, each once: what `compile()` asks its resolver for before the module is analysed. */ export function componentImports(parsed: ParsedModule): string[] { const found = new Set(); for (const statement of parsed.program.body) { + // A type-only import too: its outputs import the types from the output of the module's main + // component, which only the module's API names (ADR-0067). if ( statement.type === "ImportDeclaration" && - statement.importKind !== "type" && COMPONENT_SPECIFIER.test(statement.source.value) ) { found.add(statement.source.value); @@ -1261,6 +1262,11 @@ function componentImport(statement: AST.ImportDeclaration, context: ModuleContex context.components.set(item.local, { name: item.local.name, api: component }); } if (!names.length) return; + // A type-only import of the module before this one recorded it without names. + const typesOnly = context.imports.findIndex( + (entry) => entry.specifier === specifier && !entry.names.length, + ); + if (typesOnly !== -1) context.imports.splice(typesOnly, 1); context.imports.push( createModuleImport(specifier, api.file, api, names, { start: statement.start, @@ -1498,6 +1504,7 @@ function checkImport(statement: AST.ImportDeclaration, context: ModuleContext): } if (statement.importKind === "type") { typeImport(statement, statement.specifiers, context); + if (COMPONENT_SPECIFIER.test(specifier)) typeOnlyModule(statement, context); return; } reporter.unsupported(statement, "Importing modules is not supported yet."); @@ -1577,6 +1584,24 @@ function sharedImport(statement: AST.ImportDeclaration, context: ModuleContext): ); } +/** + * A `.uf.tsx` module the source imports types from alone: recorded with its API and no names, so + * each output imports the types from the output of its main component, which the file's name + * does not give (ADR-0067). An unresolved one is left to the type oracle, which reports the types + * it cannot read (UF6001). + */ +function typeOnlyModule(statement: AST.ImportDeclaration, context: ModuleContext): void { + const specifier = statement.source.value; + const api = context.resolved.get(specifier); + if (!api || context.imports.some((entry) => entry.specifier === specifier)) return; + context.imports.push( + createModuleImport(specifier, api.file, api, [], { + start: statement.start, + end: statement.end, + }), + ); +} + /** * An import of types from a relative `.ts`, a `.uf.tsx`, a `.uf.ts` or a package (ADR-0067): * the analyser reads them through the type oracle's shapes, and every output re-imports them diff --git a/packages/analyzer/test/corpus.test.ts b/packages/analyzer/test/corpus.test.ts index c31a7835c..23652894c 100644 --- a/packages/analyzer/test/corpus.test.ts +++ b/packages/analyzer/test/corpus.test.ts @@ -52,9 +52,9 @@ const NAMED_OTHERWISE: Readonly> = { /** * The diagnostics cases whose code `compile()` reports after the analyser, which finds nothing: - * the codegen scoper's (ADR-0063). + * the codegen scoper's (ADR-0063), and the capability check's (ADR-0068). */ -const AFTER_ANALYSIS: ReadonlySet = new Set(["unscopable-selector"]); +const AFTER_ANALYSIS: ReadonlySet = new Set(["unscopable-selector", "generic-slot-props"]); /** The warnings the corpus's feature cases hold on purpose. */ const WARNINGS: Readonly> = { diff --git a/packages/codegen/README.md b/packages/codegen/README.md index 42851028a..bb8a75df6 100644 --- a/packages/codegen/README.md +++ b/packages/codegen/README.md @@ -223,8 +223,17 @@ rewrite })` prints a component's root element or fragment, every node and attrib component's output keeps as written (a styling engine's, ADR-0064), and `addPassThroughImports` adds them to an `ImportSet`, which prints them after its sorted imports, in the IR's order; `componentTypes` and `typeDeclarationCode` copy the type declarations a component's props reach, exported as - the source does. `ImportSet` sorts imports and renames one that clashes with a name in its - `NameScope`: a target reserves the source's names (`sourceNames`: the component, its types, + the source does. `addTypeImports` adds the names of the module's `import type` declarations that + what a component's output prints reads (`liveTypeNames`), by the source's own names, from + `typeSpecifier`: a `.ts` module's or a package's as written, a `.uf.tsx` module's from the + output of its main component, which the module's import records with its API, a type-only one + included (`./TextField.vue` for `./text-field.uf.tsx`). `typeExportsCode` writes the module's + type exports in the output of its main component: an exported type no component reaches, a + re-export, and a re-export of an exported type only another component's output declares, each + with its span, so a target places it in source order (ADR-0067, ADR-0068). A prop another file + declares (`declaredBy`) is written in that file's names, so neither these nor `liveTypes` and + the kinds look its type up in the component's own module. `ImportSet` sorts imports and renames + one that clashes with a name in its `NameScope`: a target reserves the source's names (`sourceNames`: the component, its types, bindings, every variable its expressions and setup code read or declare (`codeNames`), its functions' parameters, and the types its setup's annotations name (`typeNames`)) and claims its own (`props`, `cx`, `Show`, `setCount`) from the same scope, so nothing it adds captures or diff --git a/packages/codegen/src/exports.ts b/packages/codegen/src/exports.ts index 1cbab2ee2..4959460af 100644 --- a/packages/codegen/src/exports.ts +++ b/packages/codegen/src/exports.ts @@ -1,7 +1,12 @@ import type * as AST from "@oxc-project/types"; -import type { TypeDeclaration, UfComponent, UfExport, UfModule } from "@unframework/ir"; +import type { Span, TypeDeclaration, UfComponent, UfExport, UfModule } from "@unframework/ir"; +import { keyOwner } from "./imports.ts"; +import type { ImportSet } from "./imports.ts"; import { exportDefault, exportList, exportNamed, variableDeclaration } from "./js/builders.ts"; +import { parseStatementsSource } from "./parse.ts"; +import { liveTypeNames } from "./references.ts"; +import { collectNames } from "./rewrite.ts"; /** * Declares a component with the source's export shape. A lone default export or a lone export @@ -55,9 +60,102 @@ export function componentTypes(component: UfComponent, module: UfModule): TypeDe /** * A type declaration as the source writes it, exported when the source exports it: the - * outputs keep the export, which consumers type their props with (M5). Copied as text, never - * re-printed: oxc-codegen drops comments, and a member's doc comment is part of the type. + * outputs keep the export, which consumers type their props with (ADR-0068). Copied as text, + * never re-printed: oxc-codegen drops comments, and a member's doc comment is part of the type. */ export function typeDeclarationCode(declaration: TypeDeclaration): string { return declaration.exported ? `export ${declaration.code}` : declaration.code; } + +/** + * The specifier an output imports a module's types from (ADR-0067): a `.uf.tsx` module's are + * exported by the output of its main component (`keyOwner` of the API the module's import + * records, a type-only one included), `file(name)` naming a component's output file as for + * `childImports`; any other specifier, a `.ts` module's or a package's, is kept as written, + * since every output tree holds the `.ts` modules unchanged (ADR-0070). A `.uf.tsx` module with + * no API here was not resolved, which fails the compile before any output. + */ +export function typeSpecifier( + specifier: string, + module: UfModule, + file: (name: string) => string, +): string { + if (!specifier.endsWith(".uf.tsx")) return specifier; + const entry = module.imports?.find((each) => each.specifier === specifier); + const owner = entry && keyOwner(entry.api.components); + if (!entry || owner === undefined) return specifier; + const directory = entry.file.includes("/") + ? entry.file.slice(0, entry.file.lastIndexOf("/") + 1) + : ""; + const path = `${directory}${file(owner)}`; + return path.startsWith("../") ? path : `./${path}`; +} + +/** + * Adds the `import type` names a component's output reads (ADR-0067): each name of the module's + * type imports that what the output prints reads (`liveTypeNames`), under the source's own + * local name, from `typeSpecifier`. The output of the module's main component also writes the + * module's type exports (`typeExports`), and reads what their declarations name. + */ +export function addTypeImports( + component: UfComponent, + module: UfModule, + imports: ImportSet, + file: (name: string) => string, +): void { + if (!module.typeImports?.length) return; + const read = liveTypeNames(component, module, { client: true }); + if (keyOwner(module.components, module.exports) === component.name) { + for (const entry of module.typeExports ?? []) { + if (entry.kind !== "Declaration") continue; + const [statement] = parseStatementsSource(entry.code).statements; + collectNames(statement, read); + } + } + for (const entry of module.typeImports) { + for (const name of entry.names) { + if (!read.has(name.local)) continue; + imports.add(typeSpecifier(entry.specifier, module, file), name.imported, { + type: true, + local: name.local, + exact: true, + }); + } + } +} + +/** + * The module's type exports, as the output of its main component writes them (ADR-0068): an + * exported type no component reaches as written, a re-export from `typeSpecifier`, and a + * re-export of an exported type only another component's output declares, each with its span, + * so a target can place it in source order among the types it copies. Every other component's + * output writes none. + */ +export function typeExportsCode( + component: UfComponent, + module: UfModule, + file: (name: string) => string, +): { code: string; span: Span }[] { + if (keyOwner(module.components, module.exports) !== component.name) return []; + const exports = (module.typeExports ?? []).map((entry) => { + if (entry.kind === "Declaration") return { code: `export ${entry.code}`, span: entry.span }; + const names = entry.names.map(({ imported, exported }) => + imported === exported ? imported : `${imported} as ${exported}`, + ); + const specifier = JSON.stringify(typeSpecifier(entry.specifier, module, file)); + return { code: `export type { ${names.join(", ")} } from ${specifier};`, span: entry.span }; + }); + // An exported type only another component of the module reaches is declared in that + // component's output: the main output re-exports it, so a consumer finds every exported type + // where it imports the module's (ADR-0068). + for (const declaration of module.types) { + if (!declaration.exported || component.types.includes(declaration.name)) continue; + const sibling = module.components.find((each) => each.types.includes(declaration.name)); + if (!sibling) continue; + exports.push({ + code: `export type { ${declaration.name} } from ${JSON.stringify(`./${file(sibling.name)}`)};`, + span: declaration.span, + }); + } + return exports.toSorted((a, b) => a.span.start - b.span.start); +} diff --git a/packages/codegen/src/index.ts b/packages/codegen/src/index.ts index c1f4c074e..be1449a60 100644 --- a/packages/codegen/src/index.ts +++ b/packages/codegen/src/index.ts @@ -27,7 +27,15 @@ export type { ComponentModule } from "./js/print.ts"; export { Placeholders } from "./js/placeholders.ts"; export { isAlwaysNullish, nullishText, syntacticNullishness, textTemplate } from "./js/text.ts"; export type { Nullishness, TextPart } from "./js/text.ts"; -export { componentTypes, exportDeclaration, exportsOf, typeDeclarationCode } from "./exports.ts"; +export { + addTypeImports, + componentTypes, + exportDeclaration, + exportsOf, + typeDeclarationCode, + typeExportsCode, + typeSpecifier, +} from "./exports.ts"; export { bindingOf, codeKind, @@ -53,7 +61,7 @@ export { parseStatementsSource, } from "./parse.ts"; export type { CodeKind, ExpressionComment, ParsedExpression, ParsedStatements } from "./parse.ts"; -export { liveBindings, liveTypes, referencedBindings } from "./references.ts"; +export { liveBindings, liveTypeNames, liveTypes, referencedBindings } from "./references.ts"; export type { LiveOptions, ReferencedOptions } from "./references.ts"; export { functionBodyText, diff --git a/packages/codegen/src/kinds.ts b/packages/codegen/src/kinds.ts index d6fc51930..b34a6d5b3 100644 --- a/packages/codegen/src/kinds.ts +++ b/packages/codegen/src/kinds.ts @@ -154,6 +154,8 @@ class Kinds { readonly #module: UfModule; /** The declarations and bindings being read, against cycles. */ readonly #reading = new Set(); + /** Set while a prop another file declares is read: its names are not the module's. */ + #foreign = false; constructor(component: UfComponent, module: UfModule) { this.#component = component; @@ -194,7 +196,9 @@ class Kinds { /** Whether a local `type` alias names only primitives: an interface is an object. */ #alias(name: string): boolean { - const declaration = this.#module.types.find((each) => each.name === name); + const declaration = this.#foreign + ? undefined + : this.#module.types.find((each) => each.name === name); if (!declaration || this.#reading.has(name)) return false; const alias = /^type\s+[A-Za-z_$][\w$]*\s*=\s*([\s\S]*?);?\s*$/.exec(declaration.code); if (!alias) return false; @@ -377,7 +381,16 @@ class Kinds { switch (binding.kind) { case "prop": { const prop = this.#component.props.find((each) => each.binding === binding.id); - return prop !== undefined && this.type(prop.type.code); + if (prop === undefined) return false; + // A member another file declares is written in that file's names (ADR-0067): none of + // the module's own aliases is the one it names. + if (!prop.declaredBy) return this.type(prop.type.code); + this.#foreign = true; + try { + return this.type(prop.type.code); + } finally { + this.#foreign = false; + } } case "state": { const item = this.#component.setup.find( diff --git a/packages/codegen/src/references.ts b/packages/codegen/src/references.ts index 0542742e1..e73f56e01 100644 --- a/packages/codegen/src/references.ts +++ b/packages/codegen/src/references.ts @@ -247,15 +247,39 @@ export function liveTypes( module: UfModule, options: LiveOptions, ): TypeDeclaration[] { + const { declared, reached } = reachTypes(component, module, options); + return [...declared.values()].filter((declaration) => reached.has(declaration.name)); +} + +/** + * Every type name what a component's output prints reads, as {@link liveTypes} walks it: the + * names of the declarations it copies, and the names they and the component's own annotations + * read, such as the types it imports (ADR-0067), which a target imports only where they are read. + */ +export function liveTypeNames( + component: UfComponent, + module: UfModule, + options: LiveOptions, +): Set { + return reachTypes(component, module, options).names; +} + +function reachTypes( + component: UfComponent, + module: UfModule, + options: LiveOptions, +): { declared: Map; reached: Set; names: Set } { const declared = new Map( module.types .filter((declaration) => component.types.includes(declaration.name)) .map((declaration) => [declaration.name, declaration]), ); const reached = new Set(); + const names = new Set(); const pending: string[] = []; - const reach = (names: Iterable) => { - for (const name of names) { + const reach = (found: Iterable) => { + for (const name of found) { + names.add(name); if (!declared.has(name) || reached.has(name)) continue; reached.add(name); pending.push(name); @@ -271,7 +295,13 @@ export function liveTypes( code(written.body); }; if (component.propsParameter) type(component.propsParameter.type); - for (const prop of component.props) type(prop.type); + // A member another file declares is written in that file's names (ADR-0067), which the + // component's module does not declare or import. + for (const prop of component.props) if (!prop.declaredBy) type(prop.type); + for (const parameter of component.typeParameters ?? []) { + type(parameter.constraint); + type(parameter.default); + } // A slot's props are its public type, as a prop's is (ADR-0054). for (const slot of component.slots?.slots ?? []) type(slot.props); for (const declaration of declared.values()) { @@ -346,12 +376,12 @@ export function liveTypes( } for (let name = pending.pop(); name !== undefined; name = pending.pop()) { const [statement] = parseStatementsSource(declared.get(name)!.code).statements; - const names = new Set(); - collectNames(statement, names); - names.delete(name); - reach(names); + const found = new Set(); + collectNames(statement, found); + found.delete(name); + reach(found); } - return [...declared.values()].filter((declaration) => reached.has(declaration.name)); + return { declared, reached, names }; } /** Adds to `found` the bindings code references: read, called, written or emitted through. */ diff --git a/packages/codegen/test/codegen.test.ts b/packages/codegen/test/codegen.test.ts index 016d0e084..3a11fd674 100644 --- a/packages/codegen/test/codegen.test.ts +++ b/packages/codegen/test/codegen.test.ts @@ -4,7 +4,9 @@ import { createBinding, createComponent, createElement, + createExport, createFor, + createModuleImport, createInterpolation, createModule, createProp, @@ -15,11 +17,12 @@ import { createTypeText, span, } from "@unframework/ir"; -import type { ElementNode } from "@unframework/ir"; +import type { ComponentApi, ElementNode, ModuleApi } from "@unframework/ir"; import { transformWithOxc } from "vite"; import { describe, expect, it } from "vitest"; import { + addTypeImports, componentTypes, exportDeclaration, formatOutput, @@ -40,6 +43,8 @@ import { printProgram, sourceNames, typeDeclarationCode, + typeExportsCode, + typeSpecifier, } from "../src/index.ts"; import type { JsxDialect } from "../src/index.ts"; import { expressionAt } from "./expressions.ts"; @@ -387,6 +392,145 @@ describe("type declarations", () => { }); }); +describe("type imports and exports (ADR-0067, ADR-0068)", () => { + /** A `.uf.tsx` module's API, as the resolver gives it: its main component alone. */ + const moduleApi = (file: string, name: string): ModuleApi => ({ + file, + components: [ + { + name, + export: "default", + props: [], + events: [], + models: [], + slots: [], + exposes: [], + inheritAttrs: true, + root: "element", + } satisfies ComponentApi, + ], + keys: [], + }); + + /** A module whose main component, Badge, reads `Tone` and not `Hint`, which a member another file declares names. */ + function badgeModule() { + const props = [ + createProp("tone", false, createTypeText("Tone", at), at), + createProp("hint", true, createTypeText("Hint", at), at, undefined, undefined, { + file: "types.ts", + declaration: "Base", + span: at, + }), + ]; + const badge = createComponent("Badge", createElement("p", [], [], at), at, props); + badge.propsParameter = createPropsParameter( + "object", + createTypeText("BadgeProps", at), + at, + "props", + ); + const other = createComponent("Other", createElement("p", [], [], at), at); + const module = createModule( + "Badge.uf.tsx", + [badge, other], + [createExport("default", "Badge", at)], + [], + ); + module.typeImports = [ + { + specifier: "./types", + names: [ + { imported: "BadgeProps", local: "BadgeProps", span: at }, + { imported: "Tone", local: "Tone", span: at }, + { imported: "Hint", local: "Hint", span: at }, + { imported: "Size", local: "SizeName", span: at }, + ], + span: at, + }, + ]; + // Imported for its types alone: the analyser records its API, without names. + module.imports = [ + createModuleImport( + "./Field.uf.tsx", + "Field.uf.tsx", + moduleApi("Field.uf.tsx", "Field"), + [], + at, + ), + ]; + module.typeExports = [ + { kind: "Declaration", name: "Sizes", code: "type Sizes = SizeName[];", span: span(40, 64) }, + { + kind: "ReExport", + specifier: "./Field.uf.tsx", + names: [{ imported: "FieldProps", exported: "Props", span: at }], + span: span(10, 30), + }, + ]; + return { module, badge, other }; + } + + it("imports the names the output reads, as the source binds them, and none a foreign member names", () => { + const { module, badge, other } = badgeModule(); + const imports = new ImportSet(); + addTypeImports(badge, module, imports, (name) => `${name}.vue`); + expect(printProgram(js.program(imports.toDeclarations())).trim()).toBe( + 'import type { BadgeProps, Size as SizeName, Tone } from "./types";', + ); + // Another component of the module reads none, and writes none of its exports. + const others = new ImportSet(); + addTypeImports(other, module, others, (name) => `${name}.vue`); + expect(others.size).toBe(0); + expect(typeExportsCode(other, module, (name) => `${name}.vue`)).toEqual([]); + }); + + it("writes the module's type exports in its main component's output, from each target's file", () => { + const { module, badge } = badgeModule(); + expect(typeExportsCode(badge, module, (name) => `${name}.vue`)).toEqual([ + { code: 'export type { FieldProps as Props } from "./Field.vue";', span: span(10, 30) }, + { code: "export type Sizes = SizeName[];", span: span(40, 64) }, + ]); + }); + + it("re-exports from the main output an exported type only another component's output declares", () => { + const tone = createTypeDeclaration("Tone", true, 'type Tone = "info";', span(0, 19)); + const badge = createComponent("Badge", createElement("p", [], [], at), at, [], undefined, [ + "Tone", + ]); + const pair = createComponent("Pair", createElement("p", [], [], at), at); + const module = createModule( + "Pair.uf.tsx", + [badge, pair], + [createExport("default", "Pair", at)], + [tone], + ); + expect(typeExportsCode(pair, module, (name) => `${name}.vue`)).toEqual([ + { code: 'export type { Tone } from "./Badge.vue";', span: span(0, 19) }, + ]); + // Badge declares it already; it writes no export of the module's. + expect(typeExportsCode(badge, module, (name) => `${name}.vue`)).toEqual([]); + }); + + it("imports a .uf.tsx module's types from its main component's output, and keeps any other specifier", () => { + const module = createModule("Form.uf.tsx"); + module.imports = [ + createModuleImport( + "../shared/text-field.uf.tsx", + "../shared/text-field.uf.tsx", + moduleApi("../shared/text-field.uf.tsx", "TextField"), + [], + at, + ), + ]; + expect(typeSpecifier("./types", module, (name) => `${name}.vue`)).toBe("./types"); + expect(typeSpecifier("rxjs", module, (name) => `${name}.vue`)).toBe("rxjs"); + // The main component's file, never the module's own name. + expect(typeSpecifier("../shared/text-field.uf.tsx", module, (name) => `${name}.vue`)).toBe( + "../shared/TextField.vue", + ); + }); +}); + describe("builders", () => { const print = (node: Parameters[0]) => printExpression(node); diff --git a/packages/compiler/test/types.test.ts b/packages/compiler/test/types.test.ts index cfe905df2..c681b57ab 100644 --- a/packages/compiler/test/types.test.ts +++ b/packages/compiler/test/types.test.ts @@ -192,6 +192,35 @@ describe("props types across files and packages", () => { } }); + it("imports types alone from a `.uf.tsx` module's main output, which its file name does not give", async () => { + // The resolver reads the module, so each output names its main component's file, never the + // module's own name (ADR-0067). + const files: Record = { + "text-field.uf.tsx": [ + "export interface TextFieldProps { label: string }", + "export default function TextField({ label }: TextFieldProps) { return {label}; }", + ].join("\n"), + "Form.uf.tsx": [ + 'import type { TextFieldProps } from "./text-field.uf.tsx";', + "export default function Form({ label }: TextFieldProps) { return

{label}

; }", + ].join("\n"), + }; + const read = async (path: string) => files[path.slice("/project/".length)]; + const result = await compile(files["Form.uf.tsx"]!, { + filename: "Form.uf.tsx", + targets: ["vue"], + typeOracle: createSyntacticOracle({ root: "/project", readFile: read }), + resolve: createFileResolver({ root: "/project", readFile: read }), + }); + expect(result.diagnostics).toEqual([]); + expect(result.ir!.imports).toEqual([ + expect.objectContaining({ specifier: "./text-field.uf.tsx", names: [] }), + ]); + expect(result.outputs.vue![0]!.contents).toContain( + 'import type { TextFieldProps } from "./TextField.vue";', + ); + }); + it.each([ [ "Partial", diff --git a/packages/target-angular/test/helpers.ts b/packages/target-angular/test/helpers.ts index dc0af9a3c..a7e347f93 100644 --- a/packages/target-angular/test/helpers.ts +++ b/packages/target-angular/test/helpers.ts @@ -49,9 +49,11 @@ function filesUnder(directory: string): string[] { .toSorted(); } -/** The committed Angular golden outputs of the integration corpus. */ +/** The committed Angular golden outputs of the integration corpus, but its types lane's. */ export function goldenFiles(): string[] { - return filesUnder(casesDir).filter((path) => /\/__output__\/angular\/[^/]+\.ts$/.test(path)); + return filesUnder(casesDir).filter( + (path) => /\/__output__\/angular\/[^/]+\.ts$/.test(path) && !TYPES_LANE.test(path), + ); } /** @@ -73,7 +75,8 @@ export function corpusSources(): { name: string; file: string; source: string }[ !name.startsWith("diagnostics/") && !COMPOSITION.test(name) && !STYLED_COMPOSITION.test(name) && - !BINDING_COMPOSITION.test(name), + !BINDING_COMPOSITION.test(name) && + !TYPES_LANE.test(`${name}/`), ); } @@ -92,6 +95,13 @@ const STYLED_COMPOSITION = */ const BINDING_COMPOSITION = /^bindings\/(?:style-expression|style-spread)$/; +/** + * M5's types and generics cases (ADR-0067, ADR-0068), which Angular emits from its types lane, layer + * 23 of M5 (UXF-465): until then `harness/quarantine/angular.ts` lists their failing cells, and the lane + * brings them back here. + */ +const TYPES_LANE = /(?:^|\/)(?:types|generics|diagnostics\/generic-slot-props)\//; + /** * The areas of M3's composition cases, which Angular reports (UF1002) until its M3 lane * (UXF-318) emits composition: `harness/quarantine/angular.ts` lists them, and the lane brings diff --git a/packages/target-astro/test/workspace.ts b/packages/target-astro/test/workspace.ts index ec231685c..f64988505 100644 --- a/packages/target-astro/test/workspace.ts +++ b/packages/target-astro/test/workspace.ts @@ -12,9 +12,17 @@ export const integrationRoot: string = join(repo, "tests", "integration"); /** The Astro toolchain directory: `@astrojs/check` on TypeScript 6, and its tsconfig. */ export const toolchainDir: string = join(repo, "tests", "toolchains", "astro"); -/** Every committed golden Astro output in the corpus. */ +/** + * M5's types and generics cases (ADR-0067, ADR-0068), which Astro emits from its types lane, layer + * 28 of M5 (UXF-467): until then `harness/quarantine/astro.ts` lists their failing cells, and the lane + * brings them back here. + */ +const TYPES_LANE = /(?:^|\/)(?:types|generics|diagnostics\/generic-slot-props)\//; + +/** Every committed golden Astro output in the corpus, but the cases of its types lane. */ export function goldenAstroFiles(): ToolchainFile[] { return globSync("cases/**/__output__/astro/*.astro", { cwd: integrationRoot }) + .filter((file) => !TYPES_LANE.test(file)) .toSorted() .map((file) => { const path = join(integrationRoot, file); diff --git a/packages/target-qwik/test/goldens.ts b/packages/target-qwik/test/goldens.ts index 2bbe6a343..69726fc1e 100644 --- a/packages/target-qwik/test/goldens.ts +++ b/packages/target-qwik/test/goldens.ts @@ -24,13 +24,21 @@ function expectsErrors(golden: string): boolean { ); } +/** + * M5's types and generics cases (ADR-0067, ADR-0068), which Qwik emits from its types lane, layer + * 25 of M5 (UXF-466): until then `harness/quarantine/qwik.ts` lists their failing cells, and the lane + * brings them back here. + */ +const TYPES_LANE = /(?:^|\/)(?:types|generics|diagnostics\/generic-slot-props)\//; + /** * The absolute paths of the goldens matching `pattern` (from the repository's root), sorted, but - * the scoped sheets (`.css`), which the compile project parses instead (ADR-0063). + * the scoped sheets (`.css`), which the compile project parses instead (ADR-0063), and + * the cases of its types lane (`TYPES_LANE`). */ export function goldens(pattern: string): string[] { return globSync(pattern, { cwd: repo }) .toSorted() .map((file) => join(repo, file)) - .filter((file) => !file.endsWith(".css") && !expectsErrors(file)); + .filter((file) => !file.endsWith(".css") && !expectsErrors(file) && !TYPES_LANE.test(file)); } diff --git a/packages/target-react/test/fixtures.ts b/packages/target-react/test/fixtures.ts index 75f1e71e1..780b7620e 100644 --- a/packages/target-react/test/fixtures.ts +++ b/packages/target-react/test/fixtures.ts @@ -130,16 +130,23 @@ export function lower(source: string, file = "Fixture.uf.tsx"): UfModule { return module; } +/** + * M5's types and generics cases (ADR-0067, ADR-0068), which React emits from its types lane, layer + * 16 of M5 (UXF-462): until then `harness/quarantine/react.ts` lists their failing cells, and the lane + * brings them back here. + */ +const TYPES_LANE = /(?:^|\/)(?:types|generics|diagnostics\/generic-slot-props)\//; + /** * Every committed golden output of a target, read from the integration corpus, but the scoped * sheets (`.css`), which no framework compiler or checker reads: the compile project - * parses them instead (ADR-0063). + * parses them instead (ADR-0063), and the cases of its types lane (`TYPES_LANE`). */ export function goldens(targetName: string): ToolchainFile[] { const pattern = new RegExp(`(?:^|/)__output__/${targetName}/[^/]+$`); return readdirSync(cases, { recursive: true }) .map((entry) => String(entry).split("\\").join("/")) - .filter((entry) => pattern.test(entry) && !entry.endsWith(".css")) + .filter((entry) => pattern.test(entry) && !entry.endsWith(".css") && !TYPES_LANE.test(entry)) .toSorted() .map((entry) => { const path = join(cases, entry); diff --git a/packages/target-solid/test/fixtures.ts b/packages/target-solid/test/fixtures.ts index 6b96a0ddd..4437ba382 100644 --- a/packages/target-solid/test/fixtures.ts +++ b/packages/target-solid/test/fixtures.ts @@ -88,16 +88,23 @@ export function emit( return { files: target.emit(component, context), reported }; } +/** + * M5's types and generics cases (ADR-0067, ADR-0068), which Solid emits from its types lane, layer + * 19 of M5 (UXF-463): until then `harness/quarantine/solid.ts` lists their failing cells, and the lane + * brings them back here. + */ +const TYPES_LANE = /(?:^|\/)(?:types|generics|diagnostics\/generic-slot-props)\//; + /** * Every committed golden output of a target, read from the integration corpus, but the scoped * sheets (`.css`), which no framework compiler or checker reads: the compile project - * parses them instead (ADR-0063). + * parses them instead (ADR-0063), and the cases of its types lane (`TYPES_LANE`). */ export function goldens(targetName: string): ToolchainFile[] { const pattern = new RegExp(`(?:^|/)__output__/${targetName}/[^/]+$`); return readdirSync(cases, { recursive: true }) .map((entry) => String(entry).split("\\").join("/")) - .filter((entry) => pattern.test(entry) && !entry.endsWith(".css")) + .filter((entry) => pattern.test(entry) && !entry.endsWith(".css") && !TYPES_LANE.test(entry)) .toSorted() .map((entry) => { const path = join(cases, entry); diff --git a/packages/target-svelte/test/helpers.ts b/packages/target-svelte/test/helpers.ts index 1ea778b07..99de214cc 100644 --- a/packages/target-svelte/test/helpers.ts +++ b/packages/target-svelte/test/helpers.ts @@ -27,9 +27,18 @@ function filesUnder(directory: string): string[] { .toSorted(); } -/** The committed Svelte golden outputs of the integration corpus. */ +/** + * M5's types and generics cases (ADR-0067, ADR-0068), which Svelte emits from its types lane, layer + * 21 of M5 (UXF-464): until then `harness/quarantine/svelte.ts` lists their failing cells, and the lane + * brings them back here. + */ +export const TYPES_LANE: RegExp = /(?:^|\/)(?:types|generics|diagnostics\/generic-slot-props)\//; + +/** The committed Svelte golden outputs of the integration corpus, but its types lane's. */ export function goldenFiles(): string[] { - return filesUnder(casesDir).filter((path) => /\/__output__\/svelte\/[^/]+\.svelte$/.test(path)); + return filesUnder(casesDir).filter( + (path) => /\/__output__\/svelte\/[^/]+\.svelte$/.test(path) && !TYPES_LANE.test(path), + ); } /** diff --git a/packages/target-svelte/test/typecheck.test.ts b/packages/target-svelte/test/typecheck.test.ts index ad267b0a0..3826cf829 100644 --- a/packages/target-svelte/test/typecheck.test.ts +++ b/packages/target-svelte/test/typecheck.test.ts @@ -12,6 +12,7 @@ import { packageDir, removeScratch, toolchainDir, + TYPES_LANE, writeScratch, } from "./helpers.ts"; @@ -57,7 +58,8 @@ describe("svelte typecheck", () => { expect(golden.length).toBeGreaterThan(0); // Each case in a directory of its own, where its sources' outputs import each other. const fresh: string[] = []; - for (const { modules } of corpus()) { + for (const { name, modules } of corpus()) { + if (TYPES_LANE.test(`${name}/`)) continue; const emitted: Record = {}; for (const module of modules) { for (const file of await emitFormatted(module)) emitted[file.path] = file.contents; diff --git a/packages/target-vue/README.md b/packages/target-vue/README.md index f8f7bda41..4d935a77f 100644 --- a/packages/target-vue/README.md +++ b/packages/target-vue/README.md @@ -14,10 +14,20 @@ setup as written, `.value` included (ADR-0045 to ADR-0049). In order: 1. **The imports** from `vue` (`ref`, `shallowRef`, `computed`, `watch`, `watchPostEffect`, `onMounted`, `onUnmounted`, `useTemplateRef`, `useId`, `nextTick`), each under a name no - source name takes. + source name takes, and the source's `import type` names the script reads, by name + (ADR-0067): from a `.ts` module or a package as written, `import type { BadgeProps } from +"./types"`, and from a child's output, `import Field, { type FieldProps } from "./Field.vue"`. 2. **The type declarations** the component reaches (its props, its events and its setup code), - copied as written. -3. **`defineProps

()`** with the source's annotation. + copied as written, and in the output of the module's main component its other type exports, + an exported type no component reaches and a re-export (`export type { Tone } from "./types"`), + all in source order (ADR-0068). +3. **`defineProps

()`** with the source's annotation, which compiler-sfc resolves through the + output tree: relative modules, `extends` chains, intersections and the five utility types. + A member a package declares is the exception: compiler-sfc resolves a package only through + TypeScript's classic API, which TypeScript 7 lacks, so the props are printed as a type literal + of their members, `defineProps<{ closed: boolean; label: string }>()` (ADR-0067). A generic + component's block takes its type parameters, ``, + block: ``, rewrite: template, listeners, components, @@ -252,15 +259,75 @@ function keyBlock(component: UfComponent, module: UfModule, names: VueNames): st } return `export const ${key.name}: ${injectionKey}<${type}> = Symbol(${JSON.stringify(key.description)});`; }); - const types = componentTypes(component, module).map(typeDeclarationCode); + addTypeImports(component, module, imports, (name) => `${name}.vue`); const code = [ printProgram(js.program(imports.toDeclarations())).trim(), - ...types, + ...moduleTypes(component, module), declarations.join("\n"), ].join("\n\n"); return ``; } +/** + * The types a component's output declares, in source order: those it reaches, copied as + * written, and in the output of the module's main component the module's type exports + * (ADR-0068). + */ +function moduleTypes(component: UfComponent, module: UfModule): string[] { + return [ + ...componentTypes(component, module).map((declaration) => ({ + code: typeDeclarationCode(declaration), + span: declaration.span, + })), + ...typeExportsCode(component, module, (name) => `${name}.vue`), + ] + .toSorted((a, b) => a.span.start - b.span.start) + .map(({ code }) => code); +} + +/** + * The `generic` attribute of a generic component's `")); + // The annotation's names, and only those: `Tone` is the file's, and `Observer` unread. + expect(script).toContain('import type { FieldOptions } from "./types";'); + expect(script).toContain('import type { SubscriptionLike } from "rxjs";'); + expect(script).not.toMatch(/\bObserver\b|\bTone\b/); + expect(script).toContain( + 'closed: (Pick & FieldOptions & { label: string })["closed"];', + ); + expect(script).toContain( + 'tone?: (Pick & FieldOptions & { label: string })["tone"];', + ); + expect(script).toContain("label: string;"); + }); +}); + describe("vue template", () => { it("prints conditionals on their element, or on a template around other content", async () => { const source = ` diff --git a/packages/target-vue/test/framework-compile.test.ts b/packages/target-vue/test/framework-compile.test.ts index cacb4099d..035bc7af0 100644 --- a/packages/target-vue/test/framework-compile.test.ts +++ b/packages/target-vue/test/framework-compile.test.ts @@ -1,9 +1,10 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { toolchain } from "../src/toolchain/index.ts"; -import { corpus, emitFormatted, goldenFiles, toolchainDir } from "./helpers.ts"; +import { corpus, emitFormatted, goldenFiles, goldenModules, toolchainDir } from "./helpers.ts"; import { M2_SHAPES } from "./lint-probes.ts"; const context = { toolchainDir, root: toolchainDir }; @@ -23,26 +24,40 @@ afterEach(() => { describe("vue frameworkCompile", () => { // It checks every committed golden output, so its time grows with the corpus. it("accepts every committed golden output without a warning", async () => { - const paths = goldenFiles(); + // With the `.ts` modules beside them, which compileScript reads for an imported props type. + const paths = [...goldenFiles(), ...goldenModules()].toSorted(); expect(paths.length).toBeGreaterThan(0); const results = await toolchain.frameworkCompile( paths.map((path) => ({ path, contents: readFileSync(path, "utf8") })), context, ); - expect([...results.keys()]).toEqual(paths); + expect([...results.keys()].toSorted()).toEqual(paths); for (const path of paths) expect(results.get(path), path).toEqual({ errors: [], warnings: [] }); }, 60_000); // One test per case, so no test's time grows with the corpus. As one test over the 172 cases // M2 left, it took 0.4 s alone on a laptop and overran Vitest's 5 s default in CI's Unit tests. - it.each(corpus())("accepts what the target emits today for $name", async ({ name, module }) => { - for (const file of await emitFormatted(module)) { - expect(await compileOne(file.contents, `/virtual/${name}/${file.path}`), name).toEqual({ - errors: [], - warnings: [], - }); - } - }); + it.each(corpus())( + "accepts what the target emits today for $name", + async ({ name, module, outputDir }) => { + // The `.ts` modules `compile()` copies beside the outputs (ADR-0070), which the target does + // not emit: compileScript reads them for an imported props type (ADR-0067). + const modules = (existsSync(outputDir) ? readdirSync(outputDir) : []) + .filter((file) => file.endsWith(".ts")) + .map((file) => ({ + path: `/virtual/${name}/${file}`, + contents: readFileSync(join(outputDir, file), "utf8"), + })); + for (const file of await emitFormatted(module)) { + const path = `/virtual/${name}/${file.path}`; + const results = await toolchain.frameworkCompile( + [{ path, contents: file.contents }, ...modules], + context, + ); + expect(results.get(path), name).toEqual({ errors: [], warnings: [] }); + } + }, + ); it("accepts the shapes M2 emits (lint-probes.ts), for the DOM and the server", async () => { for (const [name, contents] of Object.entries(M2_SHAPES)) { diff --git a/packages/target-vue/test/helpers.ts b/packages/target-vue/test/helpers.ts index fb0ba720c..23785d383 100644 --- a/packages/target-vue/test/helpers.ts +++ b/packages/target-vue/test/helpers.ts @@ -36,6 +36,14 @@ export function goldenFiles(): string[] { return filesUnder(casesDir).filter((path) => /\/__output__\/vue\/[^/]+\.vue$/.test(path)); } +/** + * The `.ts` modules beside the Vue golden outputs: the shared modules `compile()` copies into the + * output tree (ADR-0070), whose types a `defineProps()` imports (ADR-0067). + */ +export function goldenModules(): string[] { + return filesUnder(casesDir).filter((path) => /\/__output__\/vue\/[^/]+\.ts$/.test(path)); +} + /** * Each IR snapshot of the corpus, a case's sources each (`ir.json`, and `ir..json` for a * case of several, ADR-0057), with the directory its golden outputs live in. diff --git a/packages/target-vue/test/typecheck.test.ts b/packages/target-vue/test/typecheck.test.ts index 0423d500b..ab3ff3dc0 100644 --- a/packages/target-vue/test/typecheck.test.ts +++ b/packages/target-vue/test/typecheck.test.ts @@ -5,7 +5,14 @@ import { afterAll, describe, expect, it } from "vitest"; import { vueEventInterface } from "../src/listeners.ts"; import { toolchain } from "../src/toolchain/index.ts"; -import { goldenFiles, removeScratch, toolchainDir, writeScratch } from "./helpers.ts"; +import { + emitFormatted, + goldenFiles, + lower, + removeScratch, + toolchainDir, + writeScratch, +} from "./helpers.ts"; import { M2_SHAPES } from "./lint-probes.ts"; const context = { toolchainDir, root: toolchainDir }; @@ -18,6 +25,48 @@ const checker = { timeout: 60_000 }; afterAll(removeScratch); describe("vue typecheck", () => { + it( + "exports every type the module exports from its main output, one a local component declares too", + checker, + async () => { + // ADR-0068: `Tone` is declared in Badge's output, which only Badge reaches, and a consumer + // imports it from the module's main output. + const module = lower( + [ + 'export type Tone = "info" | "danger";', + "function Badge({ tone }: { tone: Tone }) {", + " return {tone};", + "}", + "export default function Pair() {", + ' return

;', + "}", + ].join("\n"), + "Pair.uf.tsx", + ); + const outputs = Object.fromEntries( + (await emitFormatted(module)).map((file) => [file.path, file.contents]), + ); + expect(outputs["Pair.vue"]).toContain('export type { Tone } from "./Badge.vue";'); + const paths = writeScratch({ + ...outputs, + "Use.vue": [ + '", + "", + "", + "", + ].join("\n"), + }); + const results = await toolchain.typecheck(Object.values(paths), context); + for (const path of Object.values(paths)) expect(results.get(path), path).toEqual([]); + }, + ); + it("checks the corpus and an injected error in one run, by file", checker, async () => { const golden = goldenFiles(); expect(golden.length).toBeGreaterThan(0); diff --git a/packages/testing/README.md b/packages/testing/README.md index bbcf754aa..ba3b8b725 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -55,7 +55,11 @@ describeTargets("state/counter", () => { capability. Every test of a case the target has no output for (`test.provide.ufNoOutput`, by case id, with the errors it expects for the target; the harness gives its spec a stand-in for the component) is skipped there the same way, whatever it requires, with `no output: ` on every layer, and the summary excuses it on that target alone. +errors>` on every layer, and the summary excuses it on that target alone. A case the target + cannot compile yet gets a stand-in too (`test.provide.ufStandIn`, by case id, with the cause, + ADR-0068). Its tests are not skipped: the setup first records L7, L8, L9, L10, L11 and L13 as + failed with the cause (`recordStandIn`), and `standIn` on the test's record. The summary + excuses its scenarios on that target alone, and its cells still fail. - **`view.user`** acts as a person does, through Playwright's real input (ADR-0050). An action is the inputs a person gives apart in time, each dispatched alone (the `ufInput` command): `click(target, { position? })` moves the mouse onto the element (Playwright's hover, which @@ -295,7 +299,8 @@ case)`); its projects run after the run's reference and before every other targe (case, target, layer) cell, over every test that records it. - **The parity matrix:** `ParityReporter` writes `parity-matrix..json` (version 5), with each project's cells and parity scenarios, and each test's, by its name without the target - (`testKey`): the scenarios it checked, or why it was skipped by capability (`tests`); and what + (`testKey`): the scenarios it checked, or why it was skipped by capability or ran against a + stand-in (`tests`); and what a test file recorded after its last test, the run's mode, its reference target and when it ended. It fails the run when a selected project collected no tests, when a test was skipped without recording why, when two tests of a case share a name, and, once a run has every record @@ -313,12 +318,14 @@ case)`); its projects run after the run's reference and before every other targe run, and `since` restricts it to the runs that ended since a time. Judged like the missing cells, a target whose tests check other parity scenarios than the reference's, test by test, is a problem (every target runs the same spec), except for a test it skipped by capability or - for want of output. A skip on a live layer must name a capability the target lacks - (`requires : …`, the name one of `SummaryExpectations.capabilities`) or a mechanical - cause: no output (`compile errors: no output` for the layers of a compile with errors, and - `no output: ` for a browser test of such a case), the live reference, a + for want of output, or ran against a stand-in. A skip on a live layer must name a capability + the target lacks (`requires : …`, the name one of `SummaryExpectations.capabilities`) or + a mechanical cause: no output (`compile errors: no output` for the layers of a compile with + errors, and `no output: ` for a browser test of such a case), no spec (the + browser layers of a diagnostics case its target has output for), the live reference, a component that did not render (L6), no scripted interaction. The Markdown lists each test a - target skipped, by capability or for want of output, under its case's table. + target skipped, by capability or for want of output, or ran against a stand-in, under its + case's table. ## Normalisation (`@unframework/testing/normalize`) diff --git a/packages/testing/src/browser/capabilities.ts b/packages/testing/src/browser/capabilities.ts index b0822c10d..b544b26be 100644 --- a/packages/testing/src/browser/capabilities.ts +++ b/packages/testing/src/browser/capabilities.ts @@ -64,6 +64,19 @@ export function noOutputReason( return errors === undefined ? undefined : noOutputSkip(errors); } +/** + * Why a test of a case its target cannot compile yet runs against a stand-in, or nothing: the + * case's L3 is quarantined on the target, whose browser build type-checks its output, so the + * project gives its spec a stand-in that loads (ADR-0068). The test is not skipped: every browser + * layer fails with this cause. + */ +export function standInCause( + caseId: string, + cases: Readonly> | undefined = inject("ufStandIn"), +): string | undefined { + return cases && Object.hasOwn(cases, caseId) ? cases[caseId] : undefined; +} + /** * Refuses what only a target that runs client code can do: a user's action, or reading the * events a component emitted. A test that does either declares `requires: ["interactivity"]`, diff --git a/packages/testing/src/harness.ts b/packages/testing/src/harness.ts index 6ed705e5f..94b5c4318 100644 --- a/packages/testing/src/harness.ts +++ b/packages/testing/src/harness.ts @@ -118,6 +118,13 @@ declare module "vitest" { * skipped with `no output: `. Absent outside the harness. */ ufNoOutput: Record; + /** + * Browser projects: the cases the target cannot compile yet (ADR-0068), by case id, each with + * the cause: the case's L3 is quarantined on the target, whose browser build type-checks its + * output. Their specs load against a stand-in for the component, and each test runs and + * records every browser layer as failed with the cause. Absent outside the harness. + */ + ufStandIn?: Record; /** * SSR projects of a target the run adds only as some case's reference (ADR-0057): the ids * of those cases, which it renders alone. Absent everywhere else. diff --git a/packages/testing/src/layers.ts b/packages/testing/src/layers.ts index c48aa0c9f..82d063f0b 100644 --- a/packages/testing/src/layers.ts +++ b/packages/testing/src/layers.ts @@ -73,11 +73,29 @@ export interface UfLayerMeta { * summary excuses its scenarios on that target alone. */ skipped?: string; + /** + * Why the test ran against a stand-in for its case's component, which its target cannot compile + * yet (ADR-0068): the case's L3 is quarantined there, and the target's browser build + * type-checks its output, so the component's own module would fail the spec's import. Every + * browser layer is recorded as failed with this cause, and the summary excuses the test's + * scenarios on that target alone. + */ + standIn?: string; } +/** The layers a browser test records, which a test skipped by capability records as skipped. */ +export const BROWSER_LAYERS: readonly LayerName[] = ["L7", "L8", "L9", "L10", "L11", "L13"]; + /** The skip of the layers that need output, on a target a case has compile errors for. */ export const NO_OUTPUT_SKIP = "compile errors: no output"; +/** + * The skip of the browser layers of a case without a spec, a diagnostics case, on a target it has + * output for: one whose only diagnostic is a note, as a capability's info (UF4004). No browser + * project loads it, and its server render checks the output instead. + */ +export const NO_SPEC_SKIP = "no spec: a diagnostics case renders in no browser"; + /** L13's skip in the server render of a component that did not render: L6 failed instead. */ export const NOT_RENDERED_SKIP = "the component did not render (L6)"; @@ -246,6 +264,19 @@ export function recordLayer( return settled; } +/** + * Records a test that runs against a stand-in for its case's component (ADR-0068): every browser + * layer fails with the stand-in's cause, and the test is marked so the summary excuses its + * scenarios. Not a skip: the test still runs, and a quarantine entry for each layer still needs + * it to fail, so the entries go stale once the target compiles the case. + */ +export function recordStandIn(task: LayerTask, subject: LayerSubject, cause: string): void { + for (const layer of BROWSER_LAYERS) { + recordLayer(task, subject, layer, { status: "fail", message: cause }); + } + metaOf(task, subject).standIn = cause; +} + /** * Records that a test checked a parity scenario, once per name. The summary compares each * target's scenarios of a case with the reference's. diff --git a/packages/testing/src/node/index.ts b/packages/testing/src/node/index.ts index 8edfda2d0..5c1eaa409 100644 --- a/packages/testing/src/node/index.ts +++ b/packages/testing/src/node/index.ts @@ -100,6 +100,7 @@ export type { SsrScenario, } from "../harness.ts"; export { + BROWSER_LAYERS, checkLayers, LAYERS, LayerFailure, @@ -108,10 +109,12 @@ export { NO_INTERACTION_SKIP, NO_OUTPUT_SKIP, NO_OUTPUT_TEST_SKIP, + NO_SPEC_SKIP, noOutputSkip, NOT_RENDERED_SKIP, quarantineFor, recordLayer, + recordStandIn, REQUIRES_SKIP, requiresSkip, settleOutcome, diff --git a/packages/testing/src/node/matrix.ts b/packages/testing/src/node/matrix.ts index 190177686..109ca4054 100644 --- a/packages/testing/src/node/matrix.ts +++ b/packages/testing/src/node/matrix.ts @@ -13,7 +13,10 @@ export type MatrixCells = Record>; -/** What the matrix keeps of one test: the scenarios it checked, or why it was skipped. */ +/** + * What the matrix keeps of one test: the scenarios it checked, or why it was skipped or ran + * against a stand-in. + */ export interface MatrixTest { /** The parity scenarios the test checked, sorted. */ scenarios: string[]; @@ -22,6 +25,8 @@ export interface MatrixTest { * target has no output for the case. */ skipped?: string; + /** Why the test ran against a stand-in on this target: it cannot compile the case yet. */ + standIn?: string; } /** @@ -238,18 +243,22 @@ export function mergeScenarios(sets: readonly MatrixScenarios[]): MatrixScenario } /** - * The tests of test records: each named test's sorted scenarios and capability skip. Records - * without a test name (a file's own) and tests that checked nothing and were not skipped are - * left out: they have nothing to compare. + * The tests of test records: each named test's sorted scenarios, capability skip and stand-in. + * Records without a test name (a file's own) and tests that checked nothing and were neither + * skipped nor run against a stand-in are left out: they have nothing to compare. */ export function buildTests(records: readonly UfLayerMeta[]): MatrixTests { return mergeTests( records.flatMap((record) => { - if (record.test === undefined || (!record.scenarios?.length && record.skipped === undefined)) + if ( + record.test === undefined || + (!record.scenarios?.length && record.skipped === undefined && record.standIn === undefined) + ) return []; const test: MatrixTest = { scenarios: [...new Set(record.scenarios ?? [])].sort(), ...(record.skipped === undefined ? {} : { skipped: record.skipped }), + ...(record.standIn === undefined ? {} : { standIn: record.standIn }), }; return [{ [record.case]: { [record.target]: { [record.test]: test } } }]; }), diff --git a/packages/testing/src/node/summary.ts b/packages/testing/src/node/summary.ts index 144a243e9..70301bb5e 100644 --- a/packages/testing/src/node/summary.ts +++ b/packages/testing/src/node/summary.ts @@ -13,6 +13,7 @@ import { NO_INTERACTION_SKIP, NO_OUTPUT_SKIP, NO_OUTPUT_TEST_SKIP, + NO_SPEC_SKIP, NOT_RENDERED_SKIP, REQUIRES_SKIP, } from "../layers.ts"; @@ -54,6 +55,7 @@ export interface SummaryExpectations { */ const MECHANICAL_SKIPS: readonly string[] = [ NO_OUTPUT_SKIP, + NO_SPEC_SKIP, LIVE_REFERENCE_SKIP, NOT_RENDERED_SKIP, NO_INTERACTION_SKIP, @@ -255,9 +257,9 @@ export function summarise( * The targets whose parity scenarios of a case differ from the reference's, test by test. Every * target runs the same spec, and a cell merges every test of its case, so a scenario one target * leaves out (a spec that branches on the target, a test that stops early) shows in no cell. - * A test a target skipped (by capability, or because the target has no output for the case) is - * excused on that target, and only that test: its scenarios are the ones it would have checked - * (ADR-0050). Judged like the missing cells, once every project ran all its tests: a filtered + * A test a target skipped (by capability, or because the target has no output for the case), or + * ran against a stand-in for a component it cannot compile yet (ADR-0068), is excused on that + * target, and only that test: its scenarios are the ones it would have checked (ADR-0050). Judged like the missing cells, once every project ran all its tests: a filtered * run checks only some scenarios. */ function scenarioProblems( @@ -276,7 +278,7 @@ function scenarioProblems( for (const name of [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort()) { const theirs = expected[name]; const ours = actual[name]; - if (ours?.skipped !== undefined) continue; + if (ours?.skipped !== undefined || ours?.standIn !== undefined) continue; const wanted = theirs?.skipped === undefined ? (theirs?.scenarios ?? []) : []; const checked = ours?.scenarios ?? []; const missing = wanted.filter((scenario) => !checked.includes(scenario)); @@ -292,7 +294,7 @@ function scenarioProblems( } if (!differences.length) continue; problems.push( - `${caseId} › ${target}: its parity scenarios differ from ${reference}'s: ${differences.join("; ")}. Every target runs the same spec, so every target checks the same scenarios, unless it skips a test by capability or has no output for the case.`, + `${caseId} › ${target}: its parity scenarios differ from ${reference}'s: ${differences.join("; ")}. Every target runs the same spec, so every target checks the same scenarios, unless it skips a test by capability, has no output for the case, or cannot compile it yet.`, ); } return problems; @@ -497,15 +499,20 @@ export function renderMarkdown( : [...where.targets].join(", "); lines.push(`- ${escapeCell(note)}: ${[...where.layers].join(", ")} on ${on}.`); } - // The tests skipped by capability or for want of output, which a cell that another test of - // the case passes hides. + // The tests skipped by capability or for want of output, or run against a stand-in for a + // component the target cannot compile yet (ADR-0068), which a cell that another test of the + // case passes hides. const skipped = expected.targets.flatMap((target) => Object.entries(matrix.tests[caseId]?.[target] ?? {}).flatMap(([name, test]) => - test.skipped === undefined - ? [] - : [ + test.skipped !== undefined + ? [ `- skipped on ${target} ${NO_OUTPUT_TEST_SKIP.test(test.skipped) ? "for want of output" : "by capability"}: "${escapeCell(name)}" (${escapeCell(test.skipped)}).`, - ], + ] + : test.standIn !== undefined + ? [ + `- run against a stand-in on ${target}: "${escapeCell(name)}" (${escapeCell(test.standIn)}).`, + ] + : [], ), ); lines.push(...skipped); diff --git a/packages/testing/src/setup.ts b/packages/testing/src/setup.ts index ccb6c1f55..8759a45af 100644 --- a/packages/testing/src/setup.ts +++ b/packages/testing/src/setup.ts @@ -6,7 +6,9 @@ // logged after the last test is recorded on the file and fails it; // - capabilities: a test that requires one its target does not support is skipped before it // runs, and every layer it would have recorded says why (ADR-0050); so is every test of a -// case the target has no output for, with the case's expected errors; +// case the target has no output for, with the case's expected errors; a test of a case the +// target cannot compile yet runs against a stand-in, and every browser layer fails with that +// cause (ADR-0068); // - navigation: a form submission or a link the component does not prevent is prevented, and // fails the test, rather than taking the whole spec file's frame away; // - cleanup: whatever a test mounted is unmounted inside the capture window, so unmount warnings @@ -22,7 +24,7 @@ import { commands, userEvent } from "vitest/browser"; import "./commands.ts"; import { judgeTest } from "./browser/behaviour.ts"; -import { noOutputReason, requiredSkip } from "./browser/capabilities.ts"; +import { noOutputReason, requiredSkip, standInCause } from "./browser/capabilities.ts"; import { restoreClock } from "./browser/clock.ts"; import { beginTestConsole, @@ -40,16 +42,13 @@ import { releaseKeys } from "./browser/user.ts"; import { cleanup } from "./browser/view.ts"; import { caseOfFile } from "./harness.ts"; import type { ColorScheme } from "./harness.ts"; -import { recordLayer } from "./layers.ts"; -import type { LayerName, LayerSubject } from "./layers.ts"; +import { BROWSER_LAYERS, recordLayer, recordStandIn } from "./layers.ts"; +import type { LayerSubject } from "./layers.ts"; installConsoleCapture(); boundElementTimeout(); installNavigationGuard(); -/** The layers a browser test records, which a test skipped by capability records as skipped. */ -const BROWSER_LAYERS: readonly LayerName[] = ["L7", "L8", "L9", "L10", "L11", "L13"]; - // The first parameter is the file's fixtures, a destructuring pattern (see the afterAll below). // oxlint-disable-next-line no-empty-pattern beforeAll(async ({}, suite) => { @@ -77,7 +76,15 @@ beforeEach(({ task, skip }) => { // nothing: each of its tests is skipped there, whatever it requires, with the errors the case // expects. const reason = noOutputReason(subject.case) ?? (requires ? requiredSkip(requires) : undefined); - if (reason === undefined) return; + if (reason === undefined) { + // A case the target cannot compile yet has a stand-in for its component too, which throws + // when the spec mounts it, before `expectParity` records L7, L9, L10 or L11: every browser + // layer fails here with the stand-in's cause, the test still runs, and its quarantine + // entries hold only while it fails (ADR-0068). + const cause = standInCause(subject.case); + if (cause !== undefined) recordStandIn(task, subject, cause); + return; + } // Recorded before skipping: the parity reporter fails a skip that records no reason, and the // summary excuses this test's scenarios on this target alone (`meta.uf.skipped`). for (const layer of BROWSER_LAYERS) recordLayer(task, subject, layer, { status: "skip", reason }); diff --git a/packages/testing/test/summary.test.ts b/packages/testing/test/summary.test.ts index b8c5644ad..4f0cd29df 100644 --- a/packages/testing/test/summary.test.ts +++ b/packages/testing/test/summary.test.ts @@ -622,7 +622,7 @@ describe("summarise: parity scenarios", () => { }; /** What one target's test checked: its scenarios, or the reason it was skipped. */ - type Checked = string[] | { skipped: string }; + type Checked = string[] | { skipped: string } | { standIn: string }; /** * A run of every project in which each target's browser tests checked these scenarios, by @@ -650,10 +650,15 @@ describe("summarise: parity scenarios", () => { record: { ...(Array.isArray(checked) ? { ...hello({ L7: { status: "pass" } }, target), scenarios: checked } - : { - ...hello({ L7: { status: "skip", reason: checked.skipped } }, target), - skipped: checked.skipped, - }), + : "standIn" in checked + ? { + ...hello({ L7: { status: "fail", message: checked.standIn } }, target), + standIn: checked.standIn, + } + : { + ...hello({ L7: { status: "skip", reason: checked.skipped } }, target), + skipped: checked.skipped, + }), test: `basics/hello > ${name}`, }, })), @@ -672,7 +677,7 @@ describe("summarise: parity scenarios", () => { const SKIPPED = { skipped: "requires interactivity: react runs nothing (fixture)" }; const SUFFIX = - "Every target runs the same spec, so every target checks the same scenarios, unless it skips a test by capability or has no output for the case."; + "Every target runs the same spec, so every target checks the same scenarios, unless it skips a test by capability, has no output for the case, or cannot compile it yet."; it("passes when every target's tests check the reference's scenarios, in any order", () => { const result = summarise( @@ -822,6 +827,22 @@ describe("summarise: parity scenarios", () => { ]); }); + it("excuses a test a target ran against a stand-in, on that target alone (ADR-0068)", () => { + const STAND_IN = { standIn: "react cannot compile basics/hello yet (fixture)" }; + const run = scenarios({ + vue: { renders: ["initial"], updates: ["after-click"] }, + react: { renders: STAND_IN, updates: STAND_IN }, + }); + expect(summarise([run], twoTargets).problems).toEqual([ + // Its cells fail: only its scenarios are excused, not its layers. + expect.stringContaining("basics/hello › react › L7: fail"), + ]); + expect(summarise([run], twoTargets).matrix.tests["basics/hello"]?.react).toEqual({ + "basics/hello > renders": { scenarios: [], standIn: STAND_IN.standIn }, + "basics/hello > updates": { scenarios: [], standIn: STAND_IN.standIn }, + }); + }); + it("excuses every test of a case a target has no output for, on that target alone", () => { const NO_OUTPUT = { skipped: "no output: UF4001 (The react target does not support x: y.)" }; expect( @@ -905,6 +926,7 @@ describe("summarise: skips on live layers", () => { for (const reason of [ "requires interactivity: Astro components render on the server only", "compile errors: no output", + "no spec: a diagnostics case renders in no browser", LIVE_REFERENCE_SKIP, "the component did not render (L6)", "no scripted interaction", diff --git a/tests/integration/README.md b/tests/integration/README.md index 29581bf43..36a93dba1 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -285,8 +285,9 @@ type re-exported from a case's `.ts` module, its line there (`types.ts:2`). A ki check is declared in `CONSUMER_GAPS`, with its reason, and a directive of that kind fails; so is a finer part of one, a slot without props (`named-slot`) or a slot whose props name a type parameter of its component (`generic-slot-props`), which leaves the target's other slots checked. The -`L4-consumer` canary types every output's props, events and slots `any`, so every directive must -go unmet. `../toolchains/README.md` has the layout and the notes per checker. +`L4-consumer` canary types every output's props, events and slots `any`, and declares every +exported `interface` and type alias, and each name of a type re-export, `any`, so every directive +must go unmet. `../toolchains/README.md` has the layout and the notes per checker. Each test records its layers in its task meta: `expectParity` records L7, L9, L10 and L11 without throwing, so the assertions after it always run, and the setup's `afterEach` records L8 @@ -301,8 +302,11 @@ reference's capture from the same run, nothing is written, and the matrices go t into `parity-matrix..json` and `.md`. The nightly workflow runs both engines; a difference there is an issue, never a quarantine entry. A cell is `pass`, `fail`, `skip(reason)` or `quarantined(issue)`; a skip on a live layer names a capability the target -lacks (`requires interactivity: …`) or a mechanical cause (no output, the live reference, a -component that did not render, no scripted interaction). A project that collects no tests fails, and when every +lacks (`requires interactivity: …`) or a mechanical cause (no output, no spec, the live +reference, a component that did not render, no scripted interaction). A diagnostics case has no +spec: where its target has output (its only diagnostic is a capability's note, as UF4004), the +compile project records its browser layers `skip(no spec: …)`, and its server render checks the +output. A project that collects no tests fails, and when every project ran, a missing cell fails. A project ran when an unfiltered run, or every shard of one (`--shard 1/n` to `n/n`, of the same projects and nothing else filtered), ran it; a file or test-name filter, or a missing shard, makes a partial run. In CI the summary is the parity job, @@ -321,7 +325,13 @@ of them (`--project`, `pnpm test:baselines:check`) is a partial run. compiled declares (the unplugin reports every compile; `ufComponentEvents`), never to a committed IR an update run has not written yet. A module that fails to compile fails its own spec's import only: the browser projects send Vite's error to no page, whose overlay would cover the page - every later spec clicks in. + every later spec clicks in. Such a spec records no cell, so a case its target cannot compile + yet gets a stand-in instead (ADR-0068): where the target's quarantine holds an L3 entry for + the case and its browser build type-checks its output (Angular's, through ngtsc), the spec + loads the stand-in, its tests run, and the setup records every browser layer as failed with + the stand-in's cause. The case's quarantine lists those browser cells, and the summary excuses + the tests' scenarios on that target. Once the L3 entry goes, the spec loads the real output, + and a browser entry that then passes is stale. - **Only pixels and geometry are Linux's.** `pnpm test:baselines` runs the reference alone and fails when a DOM, ARIA tree, trace or server HTML expectation reads otherwise in the container than in the repository: those are written by `pnpm test:update` wherever it runs, and must not @@ -423,7 +433,7 @@ of them (`--project`, `pnpm test:baselines:check`) is a partial run. builds it for production with no licence key (`scripts/styleframe-build.ts`), and a configuration flagged as Pro fails the same build. - `harness/no-output.ts`: the browser projects' stand-ins for the components a target has no - output for. + output for, or cannot compile yet. - `harness/canaries.ts`, `canary-verdict.ts`, `load-failures.ts`: the canaries, how `pnpm test:canaries` judges each run, and the specs a canary run could not load. - `@unframework/testing`: `describeTargets`, `mount`, `expectParity`, the normaliser, the write diff --git a/tests/integration/cases/diagnostics/generic-slot-props/Options.uf.tsx b/tests/integration/cases/diagnostics/generic-slot-props/Options.uf.tsx new file mode 100644 index 000000000..17af6f360 --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/Options.uf.tsx @@ -0,0 +1,16 @@ +// UF4004 generic-slot-props (info, Angular): a scoped slot whose props name the component's type +// parameter. Angular infers `T` from the inputs alone, so its slot directive types the fill with +// the constraint, `{ value: string }`, where every other target types it with the consumer's `T`. +import { defineSlots } from "unframework"; +import type { Element } from "unframework"; + +export default function Options({ options }: { options: T[] }) { + const slots = defineSlots<{ option?(props: { option: T }): Element }>(); + return ( +
    + {options.map((option) => ( +
  • {slots.option?.({ option })}
  • + ))} +
+ ); +} diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__expected__/diagnostics.json b/tests/integration/cases/diagnostics/generic-slot-props/__expected__/diagnostics.json new file mode 100644 index 000000000..353b3ffac --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/__expected__/diagnostics.json @@ -0,0 +1,19 @@ +[ + { + "code": "UF4004", + "severity": "info", + "message": "The angular target does not support generic-slot-props: Angular infers a component's type parameter from its inputs alone, and a slot's directive cannot infer it, so a scoped slot's context takes the parameter's constraint, where the other targets type the consumer's fill by the type argument (ADR-0068).", + "file": "diagnostics/generic-slot-props/Options.uf.tsx", + "start": { + "line": 8, + "column": 31, + "offset": 507 + }, + "end": { + "line": 8, + "column": 69, + "offset": 545 + }, + "target": "angular" + } +] diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__expected__/ssr.default.html b/tests/integration/cases/diagnostics/generic-slot-props/__expected__/ssr.default.html new file mode 100644 index 000000000..2faf2370c --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/__expected__/ssr.default.html @@ -0,0 +1,4 @@ +
    +
  • +
  • +
diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__output__/angular/options.ts b/tests/integration/cases/diagnostics/generic-slot-props/__output__/angular/options.ts new file mode 100644 index 000000000..901a71e8d --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/__output__/angular/options.ts @@ -0,0 +1,81 @@ +import { NgTemplateOutlet } from "@angular/common"; +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + TemplateRef, + contentChild, + inject, + input, +} from "@angular/core"; + +/** What a template of `Options`'s `option` slot reads: the slot's props. */ +export type OptionsOptionContext = { option: T }; + +/** A template of `Options`'s `option` slot, its `let-` variables typed (ADR-0056). */ +@Directive({ selector: "ng-template[ufOptionsOption]" }) +export class OptionsOption { + static ngTemplateContextGuard( + _directive: OptionsOption, + _context: unknown, + ): _context is OptionsOptionContext & { $implicit: OptionsOptionContext } { + return true; + } +} + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-options", + imports: [NgTemplateOutlet, ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let options = this.options(); +
    + @for (option of options; track option.value) { +
  • + @if (this.optionTemplate(); as template) { + + } +
  • + } +
+ + `, +}) +export default class Options { + readonly options = input.required(); + readonly class = input(); + readonly style = input>(); + protected readonly optionTemplate = contentChild(OptionsOption, { read: TemplateRef }); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__output__/astro/Options.astro b/tests/integration/cases/diagnostics/generic-slot-props/__output__/astro/Options.astro new file mode 100644 index 000000000..8d83e8be7 --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/__output__/astro/Options.astro @@ -0,0 +1,23 @@ +--- +interface Props { + options: T[]; + option?: (props: { option: T }) => unknown; + class?: string; + style?: Record; +} + +const { options, option: option_1, class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + +
    + {options.map((option) => ( +
  • {option_1?.({ option })}
  • + ))} +
diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__output__/ir.json b/tests/integration/cases/diagnostics/generic-slot-props/__output__/ir.json new file mode 100644 index 000000000..934b780c5 --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/__output__/ir.json @@ -0,0 +1,229 @@ +{ + "irVersion": 1, + "file": "diagnostics/generic-slot-props/Options.uf.tsx", + "components": [ + { + "name": "Options", + "span": { + "start": 383, + "end": 696 + }, + "typeParameters": [ + { + "name": "T", + "constraint": { + "code": "{ value: string }", + "span": { + "start": 425, + "end": 442 + } + }, + "span": { + "start": 415, + "end": 442 + } + } + ], + "props": [ + { + "name": "options", + "optional": false, + "type": { + "code": "T[]", + "span": { + "start": 468, + "end": 471 + } + }, + "binding": "options@446", + "span": { + "start": 459, + "end": 471 + } + } + ], + "propsParameter": { + "form": "destructured", + "type": { + "code": "{ options: T[] }", + "span": { + "start": 457, + "end": 473 + } + }, + "span": { + "start": 444, + "end": 473 + } + }, + "types": [], + "slots": { + "binding": "slots@485", + "type": { + "code": "{ option?(props: { option: T }): Element }", + "span": { + "start": 505, + "end": 547 + } + }, + "slots": [ + { + "name": "option", + "optional": true, + "props": { + "code": "{ option: T }", + "span": { + "start": 522, + "end": 535 + } + }, + "references": [ + "T" + ], + "span": { + "start": 507, + "end": 545 + } + } + ], + "span": { + "start": 479, + "end": 551 + } + }, + "bindings": [ + { + "id": "options@446", + "name": "options", + "kind": "prop", + "span": { + "start": 446, + "end": 453 + } + }, + { + "id": "slots@485", + "name": "slots", + "kind": "slots", + "span": { + "start": 485, + "end": 490 + } + }, + { + "id": "option@592", + "name": "option", + "kind": "loopVar", + "span": { + "start": 592, + "end": 598 + } + } + ], + "setup": [], + "render": { + "kind": "Element", + "tag": "ul", + "attributes": [], + "children": [ + { + "kind": "For", + "source": { + "code": "options", + "span": { + "start": 579, + "end": 586 + }, + "refs": [ + { + "kind": "Binding", + "binding": "options@446", + "span": { + "start": 579, + "end": 586 + } + } + ] + }, + "item": "option@592", + "key": { + "code": "option.value", + "span": { + "start": 622, + "end": 634 + }, + "refs": [ + { + "kind": "Binding", + "binding": "option@592", + "span": { + "start": 622, + "end": 628 + } + } + ] + }, + "body": { + "kind": "Element", + "tag": "li", + "attributes": [], + "children": [ + { + "kind": "SlotOutlet", + "slot": "option", + "props": { + "code": "{ option }", + "span": { + "start": 652, + "end": 662 + }, + "refs": [ + { + "kind": "Binding", + "binding": "option@592", + "span": { + "start": 654, + "end": 660 + }, + "shorthand": true + } + ] + }, + "fallback": [], + "span": { + "start": 636, + "end": 664 + } + } + ], + "span": { + "start": 613, + "end": 669 + } + }, + "span": { + "start": 579, + "end": 678 + } + } + ], + "span": { + "start": 567, + "end": 689 + } + } + } + ], + "exports": [ + { + "kind": "default", + "name": "default", + "local": "Options", + "span": { + "start": 383, + "end": 696 + } + } + ], + "types": [] +} diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__output__/qwik/Options.tsx b/tests/integration/cases/diagnostics/generic-slot-props/__output__/qwik/Options.tsx new file mode 100644 index 000000000..be175ba61 --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/__output__/qwik/Options.tsx @@ -0,0 +1,32 @@ +import { + type CSSProperties, + type ClassList, + type JSXOutput, + type QRL, + component$, +} from "@qwik.dev/core"; + +export interface OptionsSlots { + option$?: QRL<(props: { option: T }) => JSXOutput>; +} + +export default component$< + { options: T[] } & OptionsSlots & { class?: ClassList; style?: CSSProperties } +>(({ options, option$, class: className, style, ...attributes }) => { + return ( +
    + {options.map((option) => ( +
  • {option$?.({ option })}
  • + ))} +
+ ); +}); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__output__/react/Options.tsx b/tests/integration/cases/diagnostics/generic-slot-props/__output__/react/Options.tsx new file mode 100644 index 000000000..f7663b0e7 --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/__output__/react/Options.tsx @@ -0,0 +1,35 @@ +import type { CSSProperties, ReactNode } from "react"; + +export interface OptionsSlots { + renderOption?: (props: { option: T }) => ReactNode; +} + +export interface OptionsAttributes { + className?: string; + style?: CSSProperties; +} + +export default function Options({ + options, + renderOption, + className, + style, + ...attributes +}: { options: T[] } & OptionsSlots & OptionsAttributes) { + return ( +
    + {options.map((option) => ( +
  • {renderOption?.({ option })}
  • + ))} +
+ ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__output__/solid/Options.tsx b/tests/integration/cases/diagnostics/generic-slot-props/__output__/solid/Options.tsx new file mode 100644 index 000000000..1ff8b304f --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/__output__/solid/Options.tsx @@ -0,0 +1,22 @@ +import { For, type JSX } from "solid-js"; + +export interface OptionsSlots { + option?: (props: { option: T }) => JSX.Element; +} + +export default function Options( + props: { options: T[] } & OptionsSlots & { class?: string; style?: JSX.CSSProperties }, +) { + return ( +
    + {(option) =>
  • {props.option?.({ option })}
  • }
    +
+ ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__output__/svelte/Options.svelte b/tests/integration/cases/diagnostics/generic-slot-props/__output__/svelte/Options.svelte new file mode 100644 index 000000000..44c807c3d --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/__output__/svelte/Options.svelte @@ -0,0 +1,28 @@ + + + + +
    + {#each options as option (option.value)} +
  • {@render optionSnippet?.({ option })}
  • + {/each} +
diff --git a/tests/integration/cases/diagnostics/generic-slot-props/__output__/vue/Options.vue b/tests/integration/cases/diagnostics/generic-slot-props/__output__/vue/Options.vue new file mode 100644 index 000000000..129252ccb --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/__output__/vue/Options.vue @@ -0,0 +1,12 @@ + + + diff --git a/tests/integration/cases/diagnostics/generic-slot-props/case.json b/tests/integration/cases/diagnostics/generic-slot-props/case.json new file mode 100644 index 000000000..524731c2d --- /dev/null +++ b/tests/integration/cases/diagnostics/generic-slot-props/case.json @@ -0,0 +1,6 @@ +{ + "description": "A generic component's scoped slot whose props name its type parameter: Angular's fill takes the constraint (UF4004, info).", + "ssr": { + "default": { "props": { "options": [{ "value": "a" }, { "value": "b" }] } } + } +} diff --git a/tests/integration/cases/generics/constrained/Table.uf.tsx b/tests/integration/cases/generics/constrained/Table.uf.tsx new file mode 100644 index 000000000..403216f0e --- /dev/null +++ b/tests/integration/cases/generics/constrained/Table.uf.tsx @@ -0,0 +1,25 @@ +// A type parameter with a constraint (ADR-0068): the rows are keyed and labelled by members the +// constraint declares. +export interface TableProps { + caption: string; + rows: T[]; +} + +export default function Table({ + caption, + rows, +}: TableProps) { + return ( + + + + {rows.map((row) => ( + + + + + ))} + +
{caption}
{row.id}{row.label}
+ ); +} diff --git a/tests/integration/cases/generics/constrained/__expected__/aria.people.yaml b/tests/integration/cases/generics/constrained/__expected__/aria.people.yaml new file mode 100644 index 000000000..cb51ab411 --- /dev/null +++ b/tests/integration/cases/generics/constrained/__expected__/aria.people.yaml @@ -0,0 +1,9 @@ +- table "People": + - caption: People + - rowgroup: + - row "ada Ada Lovelace": + - rowheader "ada" + - cell "Ada Lovelace" + - row "grace Grace Hopper": + - rowheader "grace" + - cell "Grace Hopper" diff --git a/tests/integration/cases/generics/constrained/__expected__/diagnostics.json b/tests/integration/cases/generics/constrained/__expected__/diagnostics.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/tests/integration/cases/generics/constrained/__expected__/diagnostics.json @@ -0,0 +1 @@ +[] diff --git a/tests/integration/cases/generics/constrained/__expected__/dom.people.html b/tests/integration/cases/generics/constrained/__expected__/dom.people.html new file mode 100644 index 000000000..e8d1dabf0 --- /dev/null +++ b/tests/integration/cases/generics/constrained/__expected__/dom.people.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + +
+ "People" +
+ "ada" + + "Ada Lovelace" +
+ "grace" + + "Grace Hopper" +
diff --git a/tests/integration/cases/generics/constrained/__expected__/geometry.people.json b/tests/integration/cases/generics/constrained/__expected__/geometry.people.json new file mode 100644 index 000000000..63523a8b4 --- /dev/null +++ b/tests/integration/cases/generics/constrained/__expected__/geometry.people.json @@ -0,0 +1,757 @@ +{ + "version": 1, + "nodes": { + "table[0]": { + "box": [ + 8, + 8, + 160.984375, + 82 + ], + "style": { + "display": "table", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "border-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "table[0]/caption[0]": { + "box": [ + 8, + 8, + 160.984375, + 24 + ], + "style": { + "display": "table-caption", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "-webkit-center", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "table[0]/caption[0]/#text[0]": { + "box": [ + [ + 62.453125, + 10, + 52.0625, + 20 + ] + ] + }, + "table[0]/tbody[0]": { + "box": [ + 10, + 34, + 156.984375, + 54 + ], + "style": { + "display": "table-row-group", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "middle", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "table[0]/tbody[0]/tr[0]": { + "box": [ + 10, + 34, + 156.984375, + 26 + ], + "style": { + "display": "table-row", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "middle", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "table[0]/tbody[0]/tr[0]/th[0]": { + "box": [ + 10, + 34, + 46.640625, + 26 + ], + "style": { + "display": "table-cell", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "1px", + "padding-right": "1px", + "padding-bottom": "1px", + "padding-left": "1px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "700", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "center", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "middle", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "table[0]/tbody[0]/tr[0]/th[0]/#text[0]": { + "box": [ + [ + 18.984375, + 37, + 28.671875, + 20 + ] + ] + }, + "table[0]/tbody[0]/tr[0]/td[0]": { + "box": [ + 58.640625, + 34, + 108.34375, + 26 + ], + "style": { + "display": "table-cell", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "1px", + "padding-right": "1px", + "padding-bottom": "1px", + "padding-left": "1px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "middle", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "table[0]/tbody[0]/tr[0]/td[0]/#text[0]": { + "box": [ + [ + 59.640625, + 37, + 101.984375, + 20 + ] + ] + }, + "table[0]/tbody[0]/tr[1]": { + "box": [ + 10, + 62, + 156.984375, + 26 + ], + "style": { + "display": "table-row", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "middle", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "table[0]/tbody[0]/tr[1]/th[0]": { + "box": [ + 10, + 62, + 46.640625, + 26 + ], + "style": { + "display": "table-cell", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "1px", + "padding-right": "1px", + "padding-bottom": "1px", + "padding-left": "1px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "700", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "center", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "middle", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "table[0]/tbody[0]/tr[1]/th[0]/#text[0]": { + "box": [ + [ + 11, + 65, + 44.640625, + 20 + ] + ] + }, + "table[0]/tbody[0]/tr[1]/td[0]": { + "box": [ + 58.640625, + 62, + 108.34375, + 26 + ], + "style": { + "display": "table-cell", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "1px", + "padding-right": "1px", + "padding-bottom": "1px", + "padding-left": "1px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "middle", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "table[0]/tbody[0]/tr[1]/td[0]/#text[0]": { + "box": [ + [ + 59.640625, + 65, + 106.34375, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/generics/constrained/__expected__/ssr.people.html b/tests/integration/cases/generics/constrained/__expected__/ssr.people.html new file mode 100644 index 000000000..e8d1dabf0 --- /dev/null +++ b/tests/integration/cases/generics/constrained/__expected__/ssr.people.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + +
+ "People" +
+ "ada" + + "Ada Lovelace" +
+ "grace" + + "Grace Hopper" +
diff --git a/tests/integration/cases/generics/constrained/__output__/angular/table.ts b/tests/integration/cases/generics/constrained/__output__/angular/table.ts new file mode 100644 index 000000000..042ee6ebe --- /dev/null +++ b/tests/integration/cases/generics/constrained/__output__/angular/table.ts @@ -0,0 +1,68 @@ +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + inject, + input, +} from "@angular/core"; + +export interface TableProps { + caption: string; + rows: T[]; +} + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-table", + imports: [ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let caption = this.caption(); + @let rows = this.rows(); + + + + @for (row of rows; track row.id) { + + + + + } + +
{{ caption }}
{{ row.id }}{{ row.label }}
+ `, +}) +export default class Table { + readonly caption = input.required(); + readonly rows = input.required(); + readonly class = input(); + readonly style = input>(); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/generics/constrained/__output__/astro/Table.astro b/tests/integration/cases/generics/constrained/__output__/astro/Table.astro new file mode 100644 index 000000000..17df68420 --- /dev/null +++ b/tests/integration/cases/generics/constrained/__output__/astro/Table.astro @@ -0,0 +1,32 @@ +--- +export interface TableProps { + caption: string; + rows: T[]; +} + +interface Props extends TableProps { + class?: string; + style?: Record; +} + +const { caption, rows, class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + + + + + {rows.map((row) => ( + + + + + ))} + +
{caption}
{row.id}{row.label}
diff --git a/tests/integration/cases/generics/constrained/__output__/ir.json b/tests/integration/cases/generics/constrained/__output__/ir.json new file mode 100644 index 000000000..f3e8dcfbf --- /dev/null +++ b/tests/integration/cases/generics/constrained/__output__/ir.json @@ -0,0 +1,322 @@ +{ + "irVersion": 1, + "file": "generics/constrained/Table.uf.tsx", + "components": [ + { + "name": "Table", + "span": { + "start": 227, + "end": 605 + }, + "typeParameters": [ + { + "name": "T", + "constraint": { + "code": "{ id: string; label: string }", + "span": { + "start": 267, + "end": 296 + } + }, + "span": { + "start": 257, + "end": 296 + } + } + ], + "props": [ + { + "name": "caption", + "optional": false, + "type": { + "code": "string", + "span": { + "start": 203, + "end": 209 + } + }, + "binding": "caption@302", + "span": { + "start": 194, + "end": 210 + } + }, + { + "name": "rows", + "optional": false, + "type": { + "code": "T[]", + "span": { + "start": 219, + "end": 222 + } + }, + "binding": "rows@313", + "span": { + "start": 213, + "end": 223 + } + } + ], + "propsParameter": { + "form": "destructured", + "type": { + "code": "TableProps", + "span": { + "start": 322, + "end": 335 + } + }, + "span": { + "start": 298, + "end": 335 + } + }, + "types": [ + "TableProps" + ], + "bindings": [ + { + "id": "caption@302", + "name": "caption", + "kind": "prop", + "span": { + "start": 302, + "end": 309 + } + }, + { + "id": "rows@313", + "name": "rows", + "kind": "prop", + "span": { + "start": 313, + "end": 317 + } + }, + { + "id": "row@430", + "name": "row", + "kind": "loopVar", + "span": { + "start": 430, + "end": 433 + } + } + ], + "setup": [], + "render": { + "kind": "Element", + "tag": "table", + "attributes": [], + "children": [ + { + "kind": "Element", + "tag": "caption", + "attributes": [], + "children": [ + { + "kind": "Interpolation", + "value": { + "code": "caption", + "span": { + "start": 378, + "end": 385 + }, + "refs": [ + { + "kind": "Binding", + "binding": "caption@302", + "span": { + "start": 378, + "end": 385 + } + } + ] + }, + "span": { + "start": 377, + "end": 386 + } + } + ], + "span": { + "start": 368, + "end": 396 + } + }, + { + "kind": "Element", + "tag": "tbody", + "attributes": [], + "children": [ + { + "kind": "For", + "source": { + "code": "rows", + "span": { + "start": 420, + "end": 424 + }, + "refs": [ + { + "kind": "Binding", + "binding": "rows@313", + "span": { + "start": 420, + "end": 424 + } + } + ] + }, + "item": "row@430", + "key": { + "code": "row.id", + "span": { + "start": 459, + "end": 465 + }, + "refs": [ + { + "kind": "Binding", + "binding": "row@430", + "span": { + "start": 459, + "end": 462 + } + } + ] + }, + "body": { + "kind": "Element", + "tag": "tr", + "attributes": [], + "children": [ + { + "kind": "Element", + "tag": "th", + "attributes": [ + { + "kind": "Static", + "name": "scope", + "value": "row", + "span": { + "start": 484, + "end": 495 + } + } + ], + "children": [ + { + "kind": "Interpolation", + "value": { + "code": "row.id", + "span": { + "start": 497, + "end": 503 + }, + "refs": [ + { + "kind": "Binding", + "binding": "row@430", + "span": { + "start": 497, + "end": 500 + } + } + ] + }, + "span": { + "start": 496, + "end": 504 + } + } + ], + "span": { + "start": 480, + "end": 509 + } + }, + { + "kind": "Element", + "tag": "td", + "attributes": [], + "children": [ + { + "kind": "Interpolation", + "value": { + "code": "row.label", + "span": { + "start": 527, + "end": 536 + }, + "refs": [ + { + "kind": "Binding", + "binding": "row@430", + "span": { + "start": 527, + "end": 530 + } + } + ] + }, + "span": { + "start": 526, + "end": 537 + } + } + ], + "span": { + "start": 522, + "end": 542 + } + } + ], + "span": { + "start": 450, + "end": 558 + } + }, + "span": { + "start": 420, + "end": 569 + } + } + ], + "span": { + "start": 403, + "end": 585 + } + } + ], + "span": { + "start": 354, + "end": 598 + } + } + } + ], + "exports": [ + { + "kind": "default", + "name": "default", + "local": "Table", + "span": { + "start": 227, + "end": 605 + } + } + ], + "types": [ + { + "name": "TableProps", + "exported": true, + "code": "interface TableProps {\n caption: string;\n rows: T[];\n}", + "span": { + "start": 128, + "end": 225 + } + } + ] +} diff --git a/tests/integration/cases/generics/constrained/__output__/qwik/Table.tsx b/tests/integration/cases/generics/constrained/__output__/qwik/Table.tsx new file mode 100644 index 000000000..aa3267387 --- /dev/null +++ b/tests/integration/cases/generics/constrained/__output__/qwik/Table.tsx @@ -0,0 +1,33 @@ +import { type CSSProperties, type ClassList, component$ } from "@qwik.dev/core"; + +export interface TableProps { + caption: string; + rows: T[]; +} + +export default component$ & { class?: ClassList; style?: CSSProperties }>( + ({ caption, rows, class: className, style, ...attributes }) => { + return ( + + + + {rows.map((row) => ( + + + + + ))} + +
{caption}
{row.id}{row.label}
+ ); + }, +); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/generics/constrained/__output__/react/Table.tsx b/tests/integration/cases/generics/constrained/__output__/react/Table.tsx new file mode 100644 index 000000000..c87c7920a --- /dev/null +++ b/tests/integration/cases/generics/constrained/__output__/react/Table.tsx @@ -0,0 +1,42 @@ +import type { CSSProperties } from "react"; + +export interface TableProps { + caption: string; + rows: T[]; +} + +export interface TableAttributes { + className?: string; + style?: CSSProperties; +} + +export default function Table({ + caption, + rows, + className, + style, + ...attributes +}: TableProps & TableAttributes) { + return ( + + + + {rows.map((row) => ( + + + + + ))} + +
{caption}
{row.id}{row.label}
+ ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/generics/constrained/__output__/solid/Table.tsx b/tests/integration/cases/generics/constrained/__output__/solid/Table.tsx new file mode 100644 index 000000000..90f701ace --- /dev/null +++ b/tests/integration/cases/generics/constrained/__output__/solid/Table.tsx @@ -0,0 +1,33 @@ +import { For, type JSX } from "solid-js"; + +export interface TableProps { + caption: string; + rows: T[]; +} + +export default function Table( + props: TableProps & { class?: string; style?: JSX.CSSProperties }, +) { + return ( + + + + + {(row) => ( + + + + + )} + + +
{props.caption}
{row.id}{row.label}
+ ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/generics/constrained/__output__/svelte/Table.svelte b/tests/integration/cases/generics/constrained/__output__/svelte/Table.svelte new file mode 100644 index 000000000..8317ff1e6 --- /dev/null +++ b/tests/integration/cases/generics/constrained/__output__/svelte/Table.svelte @@ -0,0 +1,33 @@ + + + + + + + {#each rows as row (row.id)} + + + + {/each} + +
{caption}
{row.id}{row.label}
diff --git a/tests/integration/cases/generics/constrained/__output__/vue/Table.vue b/tests/integration/cases/generics/constrained/__output__/vue/Table.vue new file mode 100644 index 000000000..4e02aa361 --- /dev/null +++ b/tests/integration/cases/generics/constrained/__output__/vue/Table.vue @@ -0,0 +1,20 @@ + + + diff --git a/tests/integration/cases/generics/constrained/__screenshots__/people-chromium-linux.png b/tests/integration/cases/generics/constrained/__screenshots__/people-chromium-linux.png new file mode 100644 index 000000000..9f472652d Binary files /dev/null and b/tests/integration/cases/generics/constrained/__screenshots__/people-chromium-linux.png differ diff --git a/tests/integration/cases/generics/constrained/case.json b/tests/integration/cases/generics/constrained/case.json new file mode 100644 index 000000000..d6113acf9 --- /dev/null +++ b/tests/integration/cases/generics/constrained/case.json @@ -0,0 +1,14 @@ +{ + "description": "A generic component whose type parameter has a constraint, with items that hold more than it.", + "ssr": { + "people": { + "props": { + "caption": "People", + "rows": [ + { "id": "ada", "label": "Ada Lovelace", "born": 1815 }, + { "id": "grace", "label": "Grace Hopper", "born": 1906 } + ] + } + } + } +} diff --git a/tests/integration/cases/generics/constrained/constrained.test.ts b/tests/integration/cases/generics/constrained/constrained.test.ts new file mode 100644 index 000000000..142ae9f30 --- /dev/null +++ b/tests/integration/cases/generics/constrained/constrained.test.ts @@ -0,0 +1,23 @@ +// generics/constrained: `T extends { id: string; label: string }`, whose members key and label +// the rows (ADR-0068). +import { describeTargets, it, mountScenario } from "@unframework/testing"; +import { expect } from "vitest"; + +import Table from "./Table.uf.tsx"; + +/** A row of the scenario: more than the constraint declares. */ +interface Person { + id: string; + label: string; + born: number; +} + +describeTargets("generics/constrained", () => { + it("renders a row for each item", async () => { + const view = await mountScenario(Table, "people"); + await view.expectParity("people"); + await expect.element(view.getByRole("table", { name: "People" })).toBeVisible(); + await expect.element(view.getByRole("rowheader", { name: "ada" })).toBeVisible(); + await expect.element(view.getByRole("cell", { name: "Grace Hopper" })).toBeVisible(); + }); +}); diff --git a/tests/integration/cases/generics/list/List.uf.tsx b/tests/integration/cases/generics/list/List.uf.tsx new file mode 100644 index 000000000..c60375978 --- /dev/null +++ b/tests/integration/cases/generics/list/List.uf.tsx @@ -0,0 +1,18 @@ +// A component generic over its items (ADR-0068): `T` is what its consumer's `items` holds. +export interface ListProps { + label: string; + items: T[]; + selected?: T; +} + +export default function List({ label, items, selected }: ListProps) { + return ( +
    + {items.map((item, index) => ( +
  • + {String(item)} +
  • + ))} +
+ ); +} diff --git a/tests/integration/cases/generics/list/__expected__/aria.numbers.yaml b/tests/integration/cases/generics/list/__expected__/aria.numbers.yaml new file mode 100644 index 000000000..5a9871d77 --- /dev/null +++ b/tests/integration/cases/generics/list/__expected__/aria.numbers.yaml @@ -0,0 +1,4 @@ +- list "Numbers": + - listitem: "1" + - listitem: "2" + - listitem: "3" diff --git a/tests/integration/cases/generics/list/__expected__/aria.words.yaml b/tests/integration/cases/generics/list/__expected__/aria.words.yaml new file mode 100644 index 000000000..abaa27c58 --- /dev/null +++ b/tests/integration/cases/generics/list/__expected__/aria.words.yaml @@ -0,0 +1,3 @@ +- list "Words": + - listitem: a + - listitem: b diff --git a/tests/integration/cases/generics/list/__expected__/diagnostics.json b/tests/integration/cases/generics/list/__expected__/diagnostics.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/tests/integration/cases/generics/list/__expected__/diagnostics.json @@ -0,0 +1 @@ +[] diff --git a/tests/integration/cases/generics/list/__expected__/dom.numbers.html b/tests/integration/cases/generics/list/__expected__/dom.numbers.html new file mode 100644 index 000000000..7b046cf93 --- /dev/null +++ b/tests/integration/cases/generics/list/__expected__/dom.numbers.html @@ -0,0 +1,11 @@ +
    +
  • + "1" +
  • +
  • + "2" +
  • +
  • + "3" +
  • +
diff --git a/tests/integration/cases/generics/list/__expected__/dom.words.html b/tests/integration/cases/generics/list/__expected__/dom.words.html new file mode 100644 index 000000000..2fb1ad015 --- /dev/null +++ b/tests/integration/cases/generics/list/__expected__/dom.words.html @@ -0,0 +1,8 @@ +
    +
  • + "a" +
  • +
  • + "b" +
  • +
diff --git a/tests/integration/cases/generics/list/__expected__/geometry.numbers.json b/tests/integration/cases/generics/list/__expected__/geometry.numbers.json new file mode 100644 index 000000000..1e8e4d630 --- /dev/null +++ b/tests/integration/cases/generics/list/__expected__/geometry.numbers.json @@ -0,0 +1,347 @@ +{ + "version": 1, + "nodes": { + "ul[0]": { + "box": [ + 8, + 24, + 49.890625, + 72 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "16px", + "margin-right": "0px", + "margin-bottom": "16px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "40px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[0]": { + "box": [ + 48, + 24, + 9.890625, + 24 + ], + "style": { + "display": "list-item", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[0]/#text[0]": { + "box": [ + [ + 48, + 26, + 6.515625, + 20 + ] + ] + }, + "ul[0]/li[1]": { + "box": [ + 48, + 48, + 9.890625, + 24 + ], + "style": { + "display": "list-item", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[1]/#text[0]": { + "box": [ + [ + 48, + 50, + 9.765625, + 20 + ] + ] + }, + "ul[0]/li[2]": { + "box": [ + 48, + 72, + 9.890625, + 24 + ], + "style": { + "display": "list-item", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[2]/#text[0]": { + "box": [ + [ + 48, + 74, + 9.890625, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/generics/list/__expected__/geometry.words.json b/tests/integration/cases/generics/list/__expected__/geometry.words.json new file mode 100644 index 000000000..031a5f217 --- /dev/null +++ b/tests/integration/cases/generics/list/__expected__/geometry.words.json @@ -0,0 +1,259 @@ +{ + "version": 1, + "nodes": { + "ul[0]": { + "box": [ + 8, + 24, + 49.796875, + 48 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "16px", + "margin-right": "0px", + "margin-bottom": "16px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "40px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[0]": { + "box": [ + 48, + 24, + 9.796875, + 24 + ], + "style": { + "display": "list-item", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[0]/#text[0]": { + "box": [ + [ + 48, + 26, + 8.984375, + 20 + ] + ] + }, + "ul[0]/li[1]": { + "box": [ + 48, + 48, + 9.796875, + 24 + ], + "style": { + "display": "list-item", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[1]/#text[0]": { + "box": [ + [ + 48, + 50, + 9.796875, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/generics/list/__expected__/ssr.numbers.html b/tests/integration/cases/generics/list/__expected__/ssr.numbers.html new file mode 100644 index 000000000..7b046cf93 --- /dev/null +++ b/tests/integration/cases/generics/list/__expected__/ssr.numbers.html @@ -0,0 +1,11 @@ +
    +
  • + "1" +
  • +
  • + "2" +
  • +
  • + "3" +
  • +
diff --git a/tests/integration/cases/generics/list/__expected__/ssr.words.html b/tests/integration/cases/generics/list/__expected__/ssr.words.html new file mode 100644 index 000000000..2fb1ad015 --- /dev/null +++ b/tests/integration/cases/generics/list/__expected__/ssr.words.html @@ -0,0 +1,8 @@ +
    +
  • + "a" +
  • +
  • + "b" +
  • +
diff --git a/tests/integration/cases/generics/list/__output__/angular/list.ts b/tests/integration/cases/generics/list/__output__/angular/list.ts new file mode 100644 index 000000000..d495c7ab7 --- /dev/null +++ b/tests/integration/cases/generics/list/__output__/angular/list.ts @@ -0,0 +1,71 @@ +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + inject, + input, +} from "@angular/core"; + +export interface ListProps { + label: string; + items: T[]; + selected?: T; +} + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-list", + imports: [ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let label = this.label(); + @let items = this.items(); + @let selected = this.selected(); +
    + @for (item of items; track index; let index = $index) { +
  • {{ String(item) }}
  • + } +
+ `, +}) +export default class List { + readonly label = input.required(); + readonly items = input.required(); + readonly selected = input(); + readonly class = input(); + readonly style = input>(); + protected readonly String = String; + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/generics/list/__output__/astro/List.astro b/tests/integration/cases/generics/list/__output__/astro/List.astro new file mode 100644 index 000000000..cd528c110 --- /dev/null +++ b/tests/integration/cases/generics/list/__output__/astro/List.astro @@ -0,0 +1,27 @@ +--- +export interface ListProps { + label: string; + items: T[]; + selected?: T; +} + +interface Props extends ListProps { + class?: string; + style?: Record; +} + +const { label, items, selected, class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + +
    + {items.map((item) => ( +
  • {String(item)}
  • + ))} +
diff --git a/tests/integration/cases/generics/list/__output__/ir.json b/tests/integration/cases/generics/list/__output__/ir.json new file mode 100644 index 000000000..bd16131d8 --- /dev/null +++ b/tests/integration/cases/generics/list/__output__/ir.json @@ -0,0 +1,324 @@ +{ + "irVersion": 1, + "file": "generics/list/List.uf.tsx", + "components": [ + { + "name": "List", + "span": { + "start": 174, + "end": 469 + }, + "typeParameters": [ + { + "name": "T", + "span": { + "start": 203, + "end": 204 + } + } + ], + "props": [ + { + "name": "label", + "optional": false, + "type": { + "code": "string", + "span": { + "start": 133, + "end": 139 + } + }, + "binding": "label@208", + "span": { + "start": 126, + "end": 140 + } + }, + { + "name": "items", + "optional": false, + "type": { + "code": "T[]", + "span": { + "start": 150, + "end": 153 + } + }, + "binding": "items@215", + "span": { + "start": 143, + "end": 154 + } + }, + { + "name": "selected", + "optional": true, + "type": { + "code": "T", + "span": { + "start": 168, + "end": 169 + } + }, + "binding": "selected@222", + "span": { + "start": 157, + "end": 170 + } + } + ], + "propsParameter": { + "form": "destructured", + "type": { + "code": "ListProps", + "span": { + "start": 234, + "end": 246 + } + }, + "span": { + "start": 206, + "end": 246 + } + }, + "types": [ + "ListProps" + ], + "bindings": [ + { + "id": "label@208", + "name": "label", + "kind": "prop", + "span": { + "start": 208, + "end": 213 + } + }, + { + "id": "items@215", + "name": "items", + "kind": "prop", + "span": { + "start": 215, + "end": 220 + } + }, + { + "id": "selected@222", + "name": "selected", + "kind": "prop", + "span": { + "start": 222, + "end": 230 + } + }, + { + "id": "item@307", + "name": "item", + "kind": "loopVar", + "span": { + "start": 307, + "end": 311 + } + }, + { + "id": "index@313", + "name": "index", + "kind": "loopVar", + "span": { + "start": 313, + "end": 318 + } + } + ], + "setup": [], + "render": { + "kind": "Element", + "tag": "ul", + "attributes": [ + { + "kind": "Bound", + "name": "aria-label", + "value": { + "code": "label", + "span": { + "start": 281, + "end": 286 + }, + "refs": [ + { + "kind": "Binding", + "binding": "label@208", + "span": { + "start": 281, + "end": 286 + } + } + ] + }, + "span": { + "start": 269, + "end": 287 + } + } + ], + "children": [ + { + "kind": "For", + "source": { + "code": "items", + "span": { + "start": 296, + "end": 301 + }, + "refs": [ + { + "kind": "Binding", + "binding": "items@215", + "span": { + "start": 296, + "end": 301 + } + } + ] + }, + "item": "item@307", + "index": "index@313", + "key": { + "code": "index", + "span": { + "start": 342, + "end": 347 + }, + "refs": [ + { + "kind": "Binding", + "binding": "index@313", + "span": { + "start": 342, + "end": 347 + } + } + ] + }, + "body": { + "kind": "Element", + "tag": "li", + "attributes": [ + { + "kind": "Bound", + "name": "aria-current", + "value": { + "code": "item === selected ? \"true\" : undefined", + "span": { + "start": 363, + "end": 401 + }, + "refs": [ + { + "kind": "Binding", + "binding": "item@307", + "span": { + "start": 363, + "end": 367 + } + }, + { + "kind": "Binding", + "binding": "selected@222", + "span": { + "start": 372, + "end": 380 + } + }, + { + "kind": "Global", + "name": "undefined", + "span": { + "start": 392, + "end": 401 + } + } + ] + }, + "span": { + "start": 349, + "end": 402 + } + } + ], + "children": [ + { + "kind": "Interpolation", + "value": { + "code": "String(item)", + "span": { + "start": 415, + "end": 427 + }, + "refs": [ + { + "kind": "Global", + "name": "String", + "span": { + "start": 415, + "end": 421 + } + }, + { + "kind": "Binding", + "binding": "item@307", + "span": { + "start": 422, + "end": 426 + } + } + ] + }, + "span": { + "start": 414, + "end": 428 + } + } + ], + "span": { + "start": 333, + "end": 442 + } + }, + "span": { + "start": 296, + "end": 451 + } + } + ], + "span": { + "start": 265, + "end": 462 + } + } + } + ], + "exports": [ + { + "kind": "default", + "name": "default", + "local": "List", + "span": { + "start": 174, + "end": 469 + } + } + ], + "types": [ + { + "name": "ListProps", + "exported": true, + "code": "interface ListProps {\n label: string;\n items: T[];\n selected?: T;\n}", + "span": { + "start": 99, + "end": 172 + } + } + ] +} diff --git a/tests/integration/cases/generics/list/__output__/qwik/List.tsx b/tests/integration/cases/generics/list/__output__/qwik/List.tsx new file mode 100644 index 000000000..13b1cf5e2 --- /dev/null +++ b/tests/integration/cases/generics/list/__output__/qwik/List.tsx @@ -0,0 +1,30 @@ +import { type CSSProperties, type ClassList, component$ } from "@qwik.dev/core"; + +export interface ListProps { + label: string; + items: T[]; + selected?: T; +} + +export default component$ & { class?: ClassList; style?: CSSProperties }>( + ({ label, items, selected, class: className, style, ...attributes }) => { + return ( +
    + {items.map((item, index) => ( +
  • + {String(item)} +
  • + ))} +
+ ); + }, +); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/generics/list/__output__/react/List.tsx b/tests/integration/cases/generics/list/__output__/react/List.tsx new file mode 100644 index 000000000..faf54e83c --- /dev/null +++ b/tests/integration/cases/generics/list/__output__/react/List.tsx @@ -0,0 +1,40 @@ +import type { CSSProperties } from "react"; + +export interface ListProps { + label: string; + items: T[]; + selected?: T; +} + +export interface ListAttributes { + className?: string; + style?: CSSProperties; +} + +export default function List({ + label, + items, + selected, + className, + style, + ...attributes +}: ListProps & ListAttributes) { + return ( +
    + {items.map((item, index) => ( +
  • + {String(item)} +
  • + ))} +
+ ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/generics/list/__output__/solid/List.tsx b/tests/integration/cases/generics/list/__output__/solid/List.tsx new file mode 100644 index 000000000..b05652920 --- /dev/null +++ b/tests/integration/cases/generics/list/__output__/solid/List.tsx @@ -0,0 +1,31 @@ +import { For, type JSX } from "solid-js"; + +export interface ListProps { + label: string; + items: T[]; + selected?: T; +} + +export default function List(props: ListProps & { class?: string; style?: JSX.CSSProperties }) { + return ( +
    + + {(item) => ( +
  • {String(item)}
  • + )} +
    +
+ ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/generics/list/__output__/svelte/List.svelte b/tests/integration/cases/generics/list/__output__/svelte/List.svelte new file mode 100644 index 000000000..dda742692 --- /dev/null +++ b/tests/integration/cases/generics/list/__output__/svelte/List.svelte @@ -0,0 +1,28 @@ + + + + +
    + {#each items as item, index (index)} +
  • {String(item)}
  • + {/each} +
diff --git a/tests/integration/cases/generics/list/__output__/vue/List.vue b/tests/integration/cases/generics/list/__output__/vue/List.vue new file mode 100644 index 000000000..d70b8d971 --- /dev/null +++ b/tests/integration/cases/generics/list/__output__/vue/List.vue @@ -0,0 +1,19 @@ + + + diff --git a/tests/integration/cases/generics/list/__screenshots__/numbers-chromium-linux.png b/tests/integration/cases/generics/list/__screenshots__/numbers-chromium-linux.png new file mode 100644 index 000000000..6fc746e96 Binary files /dev/null and b/tests/integration/cases/generics/list/__screenshots__/numbers-chromium-linux.png differ diff --git a/tests/integration/cases/generics/list/__screenshots__/words-chromium-linux.png b/tests/integration/cases/generics/list/__screenshots__/words-chromium-linux.png new file mode 100644 index 000000000..6b3fff6b1 Binary files /dev/null and b/tests/integration/cases/generics/list/__screenshots__/words-chromium-linux.png differ diff --git a/tests/integration/cases/generics/list/case.json b/tests/integration/cases/generics/list/case.json new file mode 100644 index 000000000..403034602 --- /dev/null +++ b/tests/integration/cases/generics/list/case.json @@ -0,0 +1,7 @@ +{ + "description": "A component generic over its items, mounted with numbers and with strings.", + "ssr": { + "numbers": { "props": { "label": "Numbers", "items": [1, 2, 3], "selected": 2 } }, + "words": { "props": { "label": "Words", "items": ["a", "b"] } } + } +} diff --git a/tests/integration/cases/generics/list/list.test.ts b/tests/integration/cases/generics/list/list.test.ts new file mode 100644 index 000000000..29aea2b4e --- /dev/null +++ b/tests/integration/cases/generics/list/list.test.ts @@ -0,0 +1,21 @@ +// generics/list: `function List(props: ListProps)`, written in each target's generic form +// (ADR-0068). Each test mounts it with its own type argument, which the scenario's props hold. +import { describeTargets, it, mountScenario } from "@unframework/testing"; +import { expect } from "vitest"; + +import List from "./List.uf.tsx"; + +describeTargets("generics/list", () => { + it("renders items of any type", async () => { + const view = await mountScenario(List, "numbers"); + await view.expectParity("numbers"); + await expect.element(view.getByRole("listitem").nth(1)).toHaveTextContent("2"); + await expect.element(view.getByRole("listitem").nth(1)).toHaveAttribute("aria-current", "true"); + }); + + it("renders items of another type", async () => { + const view = await mountScenario(List, "words"); + await view.expectParity("words"); + await expect.element(view.getByRole("list", { name: "Words" })).toHaveTextContent("ab"); + }); +}); diff --git a/tests/integration/cases/generics/scoped-slot/Fruits.uf.tsx b/tests/integration/cases/generics/scoped-slot/Fruits.uf.tsx new file mode 100644 index 000000000..27a26a7c1 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/Fruits.uf.tsx @@ -0,0 +1,21 @@ +import Picker from "./Picker.uf.tsx"; + +interface Fruit { + name: string; + count: number; +} + +// The harness parent (ADR-0057): its fill reads the members of its own item type. +export default function Fruits({ fruits }: { fruits: Fruit[] }) { + return ( + + {{ + item: ({ item }) => ( + + {item.name} × {item.count} + + ), + }} + + ); +} diff --git a/tests/integration/cases/generics/scoped-slot/Picker.uf.tsx b/tests/integration/cases/generics/scoped-slot/Picker.uf.tsx new file mode 100644 index 000000000..0f3acf32b --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/Picker.uf.tsx @@ -0,0 +1,18 @@ +import { defineSlots } from "unframework"; +import type { Element } from "unframework"; + +// A scoped slot whose props name the type parameter (ADR-0068): a consumer's fill reads `T`. +export interface PickerProps { + items: T[]; +} + +export default function Picker({ items }: PickerProps) { + const slots = defineSlots<{ item?(props: { item: T }): Element }>(); + return ( +
    + {items.map((item, index) => ( +
  • {slots.item?.({ item })}
  • + ))} +
+ ); +} diff --git a/tests/integration/cases/generics/scoped-slot/__expected__/aria.basket.yaml b/tests/integration/cases/generics/scoped-slot/__expected__/aria.basket.yaml new file mode 100644 index 000000000..25ce8c921 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__expected__/aria.basket.yaml @@ -0,0 +1,3 @@ +- list: + - listitem: Apple × 3 + - listitem: Pear × 1 diff --git a/tests/integration/cases/generics/scoped-slot/__expected__/diagnostics.json b/tests/integration/cases/generics/scoped-slot/__expected__/diagnostics.json new file mode 100644 index 000000000..499c36c42 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__expected__/diagnostics.json @@ -0,0 +1,36 @@ +[ + { + "code": "UF4004", + "severity": "info", + "message": "The angular target does not support generic-slot-props: Angular infers a component's type parameter from its inputs alone, and a slot's directive cannot infer it, so a scoped slot's context takes the parameter's constraint, where the other targets type the consumer's fill by the type argument (ADR-0068).", + "file": "generics/scoped-slot/Fruits.uf.tsx", + "start": { + "line": 13, + "column": 9, + "offset": 298 + }, + "end": { + "line": 17, + "column": 10, + "offset": 403 + }, + "target": "angular" + }, + { + "code": "UF4004", + "severity": "info", + "message": "The angular target does not support generic-slot-props: Angular infers a component's type parameter from its inputs alone, and a slot's directive cannot infer it, so a scoped slot's context takes the parameter's constraint, where the other targets type the consumer's fill by the type argument (ADR-0068).", + "file": "generics/scoped-slot/Picker.uf.tsx", + "start": { + "line": 10, + "column": 31, + "offset": 326 + }, + "end": { + "line": 10, + "column": 65, + "offset": 360 + }, + "target": "angular" + } +] diff --git a/tests/integration/cases/generics/scoped-slot/__expected__/dom.basket.html b/tests/integration/cases/generics/scoped-slot/__expected__/dom.basket.html new file mode 100644 index 000000000..b006a70ce --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__expected__/dom.basket.html @@ -0,0 +1,12 @@ +
    +
  • + + "Apple × 3" + +
  • +
  • + + "Pear × 1" + +
  • +
diff --git a/tests/integration/cases/generics/scoped-slot/__expected__/geometry.basket.json b/tests/integration/cases/generics/scoped-slot/__expected__/geometry.basket.json new file mode 100644 index 000000000..a4cd7be73 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__expected__/geometry.basket.json @@ -0,0 +1,415 @@ +{ + "version": 1, + "nodes": { + "ul[0]": { + "box": [ + 8, + 24, + 113.3125, + 48 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "16px", + "margin-right": "0px", + "margin-bottom": "16px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "40px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[0]": { + "box": [ + 48, + 24, + 73.3125, + 24 + ], + "style": { + "display": "list-item", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[0]/span[0]": { + "box": [ + 48, + 26, + 73.3125, + 20 + ], + "style": { + "display": "inline", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[0]/span[0]/#text[0]": { + "box": [ + [ + 48, + 26, + 73.3125, + 20 + ] + ] + }, + "ul[0]/li[1]": { + "box": [ + 48, + 48, + 73.3125, + 24 + ], + "style": { + "display": "list-item", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[1]/span[0]": { + "box": [ + 48, + 50, + 60.71875, + 20 + ], + "style": { + "display": "inline", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "ul[0]/li[1]/span[0]/#text[0]": { + "box": [ + [ + 48, + 50, + 60.71875, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/generics/scoped-slot/__expected__/ssr.basket.html b/tests/integration/cases/generics/scoped-slot/__expected__/ssr.basket.html new file mode 100644 index 000000000..b006a70ce --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__expected__/ssr.basket.html @@ -0,0 +1,12 @@ +
    +
  • + + "Apple × 3" + +
  • +
  • + + "Pear × 1" + +
  • +
diff --git a/tests/integration/cases/generics/scoped-slot/__output__/angular/fruits.ts b/tests/integration/cases/generics/scoped-slot/__output__/angular/fruits.ts new file mode 100644 index 000000000..2aef2355f --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/angular/fruits.ts @@ -0,0 +1,61 @@ +import Picker, { PickerItem } from "./picker"; +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + inject, + input, +} from "@angular/core"; + +interface Fruit { + name: string; + count: number; +} + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-fruits", + imports: [Picker, PickerItem, ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let fruits = this.fruits(); + + + {{ item.name }} × {{ item.count }} + + + `, +}) +export default class Fruits { + readonly fruits = input.required(); + readonly class = input(); + readonly style = input>(); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/generics/scoped-slot/__output__/angular/picker.ts b/tests/integration/cases/generics/scoped-slot/__output__/angular/picker.ts new file mode 100644 index 000000000..dd8020741 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/angular/picker.ts @@ -0,0 +1,85 @@ +import { NgTemplateOutlet } from "@angular/common"; +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + TemplateRef, + contentChild, + inject, + input, +} from "@angular/core"; + +export interface PickerProps { + items: T[]; +} + +/** What a template of `Picker`'s `item` slot reads: the slot's props. */ +export type PickerItemContext = { item: T }; + +/** A template of `Picker`'s `item` slot, its `let-` variables typed (ADR-0056). */ +@Directive({ selector: "ng-template[ufPickerItem]" }) +export class PickerItem { + static ngTemplateContextGuard( + _directive: PickerItem, + _context: unknown, + ): _context is PickerItemContext & { $implicit: PickerItemContext } { + return true; + } +} + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-picker", + imports: [NgTemplateOutlet, ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let items = this.items(); +
    + @for (item of items; track index; let index = $index) { +
  • + @if (this.itemTemplate(); as template) { + + } +
  • + } +
+ + `, +}) +export default class Picker { + readonly items = input.required(); + readonly class = input(); + readonly style = input>(); + protected readonly itemTemplate = contentChild(PickerItem, { read: TemplateRef }); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/generics/scoped-slot/__output__/astro/Fruits.astro b/tests/integration/cases/generics/scoped-slot/__output__/astro/Fruits.astro new file mode 100644 index 000000000..eeeafaf02 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/astro/Fruits.astro @@ -0,0 +1,31 @@ +--- +import Picker from "./Picker.astro"; + +interface Fruit { + name: string; + count: number; +} + +interface Props { + fruits: Fruit[]; + class?: string; + style?: Record; +} + +const { fruits, class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + + {item.name} × {item.count}} + {...scopeAttributes(Astro.props)} +/> diff --git a/tests/integration/cases/generics/scoped-slot/__output__/astro/Picker.astro b/tests/integration/cases/generics/scoped-slot/__output__/astro/Picker.astro new file mode 100644 index 000000000..97cafe046 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/astro/Picker.astro @@ -0,0 +1,26 @@ +--- +export interface PickerProps { + items: T[]; +} + +interface Props extends PickerProps { + item?: (props: { item: T }) => unknown; + class?: string; + style?: Record; +} + +const { items, item: item_1, class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + +
    + {items.map((item) => ( +
  • {item_1?.({ item })}
  • + ))} +
diff --git a/tests/integration/cases/generics/scoped-slot/__output__/ir.Picker.json b/tests/integration/cases/generics/scoped-slot/__output__/ir.Picker.json new file mode 100644 index 000000000..8c9ede11c --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/ir.Picker.json @@ -0,0 +1,244 @@ +{ + "irVersion": 1, + "file": "generics/scoped-slot/Picker.uf.tsx", + "components": [ + { + "name": "Picker", + "span": { + "start": 233, + "end": 503 + }, + "typeParameters": [ + { + "name": "T", + "span": { + "start": 264, + "end": 265 + } + } + ], + "props": [ + { + "name": "items", + "optional": false, + "type": { + "code": "T[]", + "span": { + "start": 225, + "end": 228 + } + }, + "binding": "items@269", + "span": { + "start": 218, + "end": 229 + } + } + ], + "propsParameter": { + "form": "destructured", + "type": { + "code": "PickerProps", + "span": { + "start": 278, + "end": 292 + } + }, + "span": { + "start": 267, + "end": 292 + } + }, + "types": [ + "PickerProps" + ], + "slots": { + "binding": "slots@304", + "type": { + "code": "{ item?(props: { item: T }): Element }", + "span": { + "start": 324, + "end": 362 + } + }, + "slots": [ + { + "name": "item", + "optional": true, + "props": { + "code": "{ item: T }", + "span": { + "start": 339, + "end": 350 + } + }, + "references": [ + "T" + ], + "span": { + "start": 326, + "end": 360 + } + } + ], + "span": { + "start": 298, + "end": 366 + } + }, + "bindings": [ + { + "id": "items@269", + "name": "items", + "kind": "prop", + "span": { + "start": 269, + "end": 274 + } + }, + { + "id": "slots@304", + "name": "slots", + "kind": "slots", + "span": { + "start": 304, + "end": 309 + } + }, + { + "id": "item@405", + "name": "item", + "kind": "loopVar", + "span": { + "start": 405, + "end": 409 + } + }, + { + "id": "index@411", + "name": "index", + "kind": "loopVar", + "span": { + "start": 411, + "end": 416 + } + } + ], + "setup": [], + "render": { + "kind": "Element", + "tag": "ul", + "attributes": [], + "children": [ + { + "kind": "For", + "source": { + "code": "items", + "span": { + "start": 394, + "end": 399 + }, + "refs": [ + { + "kind": "Binding", + "binding": "items@269", + "span": { + "start": 394, + "end": 399 + } + } + ] + }, + "item": "item@405", + "index": "index@411", + "key": { + "code": "index", + "span": { + "start": 440, + "end": 445 + }, + "refs": [ + { + "kind": "Binding", + "binding": "index@411", + "span": { + "start": 440, + "end": 445 + } + } + ] + }, + "body": { + "kind": "Element", + "tag": "li", + "attributes": [], + "children": [ + { + "kind": "SlotOutlet", + "slot": "item", + "props": { + "code": "{ item }", + "span": { + "start": 461, + "end": 469 + }, + "refs": [ + { + "kind": "Binding", + "binding": "item@405", + "span": { + "start": 463, + "end": 467 + }, + "shorthand": true + } + ] + }, + "fallback": [], + "span": { + "start": 447, + "end": 471 + } + } + ], + "span": { + "start": 431, + "end": 476 + } + }, + "span": { + "start": 394, + "end": 485 + } + } + ], + "span": { + "start": 382, + "end": 496 + } + } + } + ], + "exports": [ + { + "kind": "default", + "name": "default", + "local": "Picker", + "span": { + "start": 233, + "end": 503 + } + } + ], + "types": [ + { + "name": "PickerProps", + "exported": true, + "code": "interface PickerProps {\n items: T[];\n}", + "span": { + "start": 189, + "end": 231 + } + } + ] +} diff --git a/tests/integration/cases/generics/scoped-slot/__output__/ir.json b/tests/integration/cases/generics/scoped-slot/__output__/ir.json new file mode 100644 index 000000000..0ef395c87 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/ir.json @@ -0,0 +1,289 @@ +{ + "irVersion": 1, + "file": "generics/scoped-slot/Fruits.uf.tsx", + "components": [ + { + "name": "Fruits", + "span": { + "start": 176, + "end": 434 + }, + "props": [ + { + "name": "fruits", + "optional": false, + "type": { + "code": "Fruit[]", + "span": { + "start": 229, + "end": 236 + } + }, + "binding": "fruits@209", + "span": { + "start": 221, + "end": 236 + } + } + ], + "propsParameter": { + "form": "destructured", + "type": { + "code": "{ fruits: Fruit[] }", + "span": { + "start": 219, + "end": 238 + } + }, + "span": { + "start": 207, + "end": 238 + } + }, + "types": [ + "Fruit" + ], + "bindings": [ + { + "id": "fruits@209", + "name": "fruits", + "kind": "prop", + "span": { + "start": 209, + "end": 215 + } + }, + { + "id": "item@307", + "name": "item", + "kind": "slotScope", + "span": { + "start": 307, + "end": 311 + } + } + ], + "setup": [], + "render": { + "kind": "Fragment", + "children": [ + { + "kind": "Component", + "component": "Picker", + "attributes": [ + { + "kind": "Prop", + "name": "items", + "value": { + "code": "fruits", + "span": { + "start": 272, + "end": 278 + }, + "refs": [ + { + "kind": "Binding", + "binding": "fruits@209", + "span": { + "start": 272, + "end": 278 + } + } + ] + }, + "span": { + "start": 265, + "end": 279 + } + } + ], + "fills": [ + { + "slot": "item", + "parameter": { + "pattern": { + "code": "{ item }", + "names": [ + "item" + ], + "span": { + "start": 305, + "end": 313 + } + }, + "span": { + "start": 305, + "end": 313 + } + }, + "children": [ + { + "kind": "Element", + "tag": "span", + "attributes": [], + "children": [ + { + "kind": "Interpolation", + "value": { + "code": "item.name", + "span": { + "start": 350, + "end": 359 + }, + "refs": [ + { + "kind": "Binding", + "binding": "item@307", + "span": { + "start": 350, + "end": 354 + } + } + ] + }, + "span": { + "start": 349, + "end": 360 + } + }, + { + "kind": "Text", + "value": " × ", + "span": { + "start": 360, + "end": 363 + } + }, + { + "kind": "Interpolation", + "value": { + "code": "item.count", + "span": { + "start": 364, + "end": 374 + }, + "refs": [ + { + "kind": "Binding", + "binding": "item@307", + "span": { + "start": 364, + "end": 368 + } + } + ] + }, + "span": { + "start": 363, + "end": 375 + } + } + ], + "span": { + "start": 330, + "end": 393 + } + } + ], + "span": { + "start": 298, + "end": 403 + } + } + ], + "span": { + "start": 257, + "end": 427 + } + } + ], + "span": { + "start": 257, + "end": 427 + } + } + } + ], + "exports": [ + { + "kind": "default", + "name": "default", + "local": "Fruits", + "span": { + "start": 176, + "end": 434 + } + } + ], + "types": [ + { + "name": "Fruit", + "exported": false, + "code": "interface Fruit {\n name: string;\n count: number;\n}", + "span": { + "start": 39, + "end": 91 + } + } + ], + "imports": [ + { + "specifier": "./Picker.uf.tsx", + "file": "Picker.uf.tsx", + "api": { + "file": "Picker.uf.tsx", + "components": [ + { + "name": "Picker", + "export": "default", + "props": [ + { + "name": "items", + "optional": false, + "type": "T[]", + "references": [ + "T" + ] + } + ], + "events": [], + "models": [], + "slots": [ + { + "name": "item", + "optional": true, + "props": "{ item: T }", + "references": [ + "T" + ] + } + ], + "exposes": [], + "inheritAttrs": true, + "root": "element", + "typeParameters": [ + "T" + ], + "rootTag": "ul" + } + ], + "keys": [] + }, + "names": [ + { + "kind": "Component", + "imported": "default", + "local": "Picker", + "span": { + "start": 7, + "end": 13 + } + } + ], + "span": { + "start": 0, + "end": 37 + } + } + ] +} diff --git a/tests/integration/cases/generics/scoped-slot/__output__/qwik/Fruits.tsx b/tests/integration/cases/generics/scoped-slot/__output__/qwik/Fruits.tsx new file mode 100644 index 000000000..3a9d1586a --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/qwik/Fruits.tsx @@ -0,0 +1,36 @@ +import Picker from "./Picker"; +import { type CSSProperties, type ClassList, component$ } from "@qwik.dev/core"; + +interface Fruit { + name: string; + count: number; +} + +export default component$<{ fruits: Fruit[] } & { class?: ClassList; style?: CSSProperties }>( + ({ fruits, class: className, style, ...attributes }) => { + return ( + <> + ( + + {item.name} × {item.count} + + )} + class={className} + style={style} + /> + + ); + }, +); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/generics/scoped-slot/__output__/qwik/Picker.tsx b/tests/integration/cases/generics/scoped-slot/__output__/qwik/Picker.tsx new file mode 100644 index 000000000..25f4a2611 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/qwik/Picker.tsx @@ -0,0 +1,36 @@ +import { + type CSSProperties, + type ClassList, + type JSXOutput, + type QRL, + component$, +} from "@qwik.dev/core"; + +export interface PickerProps { + items: T[]; +} + +export interface PickerSlots { + item$?: QRL<(props: { item: T }) => JSXOutput>; +} + +export default component$< + PickerProps & PickerSlots & { class?: ClassList; style?: CSSProperties } +>(({ items, item$, class: className, style, ...attributes }) => { + return ( +
    + {items.map((item, index) => ( +
  • {item$?.({ item })}
  • + ))} +
+ ); +}); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/generics/scoped-slot/__output__/react/Fruits.tsx b/tests/integration/cases/generics/scoped-slot/__output__/react/Fruits.tsx new file mode 100644 index 000000000..3d048fea0 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/react/Fruits.tsx @@ -0,0 +1,42 @@ +import Picker from "./Picker"; +import type { CSSProperties } from "react"; + +interface Fruit { + name: string; + count: number; +} + +export interface FruitsAttributes { + className?: string; + style?: CSSProperties; +} + +export default function Fruits({ + fruits, + className, + style, + ...attributes +}: { fruits: Fruit[] } & FruitsAttributes) { + return ( + ( + + {item.name} × {item.count} + + )} + /> + ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/generics/scoped-slot/__output__/react/Picker.tsx b/tests/integration/cases/generics/scoped-slot/__output__/react/Picker.tsx new file mode 100644 index 000000000..633d532cc --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/react/Picker.tsx @@ -0,0 +1,39 @@ +import type { CSSProperties, ReactNode } from "react"; + +export interface PickerProps { + items: T[]; +} + +export interface PickerSlots { + renderItem?: (props: { item: T }) => ReactNode; +} + +export interface PickerAttributes { + className?: string; + style?: CSSProperties; +} + +export default function Picker({ + items, + renderItem, + className, + style, + ...attributes +}: PickerProps & PickerSlots & PickerAttributes) { + return ( +
    + {items.map((item, index) => ( +
  • {renderItem?.({ item })}
  • + ))} +
+ ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/generics/scoped-slot/__output__/solid/Fruits.tsx b/tests/integration/cases/generics/scoped-slot/__output__/solid/Fruits.tsx new file mode 100644 index 000000000..8c15f8e3f --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/solid/Fruits.tsx @@ -0,0 +1,32 @@ +import Picker from "./Picker"; +import type { JSX } from "solid-js"; + +interface Fruit { + name: string; + count: number; +} + +export default function Fruits( + props: { fruits: Fruit[] } & { class?: string; style?: JSX.CSSProperties }, +) { + return ( + ( + + {item.name} × {item.count} + + )} + /> + ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/generics/scoped-slot/__output__/solid/Picker.tsx b/tests/integration/cases/generics/scoped-slot/__output__/solid/Picker.tsx new file mode 100644 index 000000000..57e4819c7 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/solid/Picker.tsx @@ -0,0 +1,26 @@ +import { For, type JSX } from "solid-js"; + +export interface PickerProps { + items: T[]; +} + +export interface PickerSlots { + item?: (props: { item: T }) => JSX.Element; +} + +export default function Picker( + props: PickerProps & PickerSlots & { class?: string; style?: JSX.CSSProperties }, +) { + return ( +
    + {(item) =>
  • {props.item?.({ item })}
  • }
    +
+ ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/generics/scoped-slot/__output__/svelte/Fruits.svelte b/tests/integration/cases/generics/scoped-slot/__output__/svelte/Fruits.svelte new file mode 100644 index 000000000..665966c0f --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/svelte/Fruits.svelte @@ -0,0 +1,28 @@ + + + + + + {#snippet item({ item })} + {item.name} × {item.count} + {/snippet} + diff --git a/tests/integration/cases/generics/scoped-slot/__output__/svelte/Picker.svelte b/tests/integration/cases/generics/scoped-slot/__output__/svelte/Picker.svelte new file mode 100644 index 000000000..7a812e5a6 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/svelte/Picker.svelte @@ -0,0 +1,31 @@ + + + + +
    + {#each items as item, index (index)} +
  • {@render itemSnippet?.({ item })}
  • + {/each} +
diff --git a/tests/integration/cases/generics/scoped-slot/__output__/vue/Fruits.vue b/tests/integration/cases/generics/scoped-slot/__output__/vue/Fruits.vue new file mode 100644 index 000000000..006e388c6 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/vue/Fruits.vue @@ -0,0 +1,18 @@ + + + diff --git a/tests/integration/cases/generics/scoped-slot/__output__/vue/Picker.vue b/tests/integration/cases/generics/scoped-slot/__output__/vue/Picker.vue new file mode 100644 index 000000000..67e1c653e --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/__output__/vue/Picker.vue @@ -0,0 +1,16 @@ + + + diff --git a/tests/integration/cases/generics/scoped-slot/__screenshots__/basket-chromium-linux.png b/tests/integration/cases/generics/scoped-slot/__screenshots__/basket-chromium-linux.png new file mode 100644 index 000000000..af7120718 Binary files /dev/null and b/tests/integration/cases/generics/scoped-slot/__screenshots__/basket-chromium-linux.png differ diff --git a/tests/integration/cases/generics/scoped-slot/case.json b/tests/integration/cases/generics/scoped-slot/case.json new file mode 100644 index 000000000..bb3130776 --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/case.json @@ -0,0 +1,14 @@ +{ + "description": "A generic component's scoped slot, filled by a parent that reads its item type.", + "main": "Fruits.uf.tsx", + "ssr": { + "basket": { + "props": { + "fruits": [ + { "name": "Apple", "count": 3 }, + { "name": "Pear", "count": 1 } + ] + } + } + } +} diff --git a/tests/integration/cases/generics/scoped-slot/scoped-slot.test.ts b/tests/integration/cases/generics/scoped-slot/scoped-slot.test.ts new file mode 100644 index 000000000..3848567fb --- /dev/null +++ b/tests/integration/cases/generics/scoped-slot/scoped-slot.test.ts @@ -0,0 +1,15 @@ +// generics/scoped-slot: a generic component's scoped slot, whose fill reads the item's own type +// (ADR-0068). +import { describeTargets, it, mountScenario } from "@unframework/testing"; +import { expect } from "vitest"; + +import Fruits from "./Fruits.uf.tsx"; + +describeTargets("generics/scoped-slot", () => { + it("renders the fill with each item", async () => { + const view = await mountScenario(Fruits, "basket"); + await view.expectParity("basket"); + await expect.element(view.getByRole("listitem").nth(0)).toHaveTextContent("Apple × 3"); + await expect.element(view.getByRole("listitem").nth(1)).toHaveTextContent("Pear × 1"); + }); +}); diff --git a/tests/integration/cases/generics/select/Select.uf.tsx b/tests/integration/cases/generics/select/Select.uf.tsx new file mode 100644 index 000000000..c63d92838 --- /dev/null +++ b/tests/integration/cases/generics/select/Select.uf.tsx @@ -0,0 +1,23 @@ +import { defineModel } from "unframework"; + +// A generic model (ADR-0068): the value is one of the consumer's options. +export interface SelectProps { + label: string; + options: readonly T[]; +} + +export default function Select({ label, options }: SelectProps) { + const value = defineModel("value", { required: true }); + return ( + + ); +} diff --git a/tests/integration/cases/generics/select/SizeForm.uf.tsx b/tests/integration/cases/generics/select/SizeForm.uf.tsx new file mode 100644 index 000000000..e11520c73 --- /dev/null +++ b/tests/integration/cases/generics/select/SizeForm.uf.tsx @@ -0,0 +1,17 @@ +import { ref } from "unframework"; + +import Select from "./Select.uf.tsx"; + +type Size = "s" | "m" | "l"; + +// The harness parent (ADR-0057): it binds the generic model to a state of its own type. +export default function SizeForm() { + const sizes: Size[] = ["s", "m", "l"]; + const size = ref("m"); + return ( +
+ + + + + + + + "Chosen: m" + +
diff --git a/tests/integration/cases/generics/select/__expected__/dom.large.html b/tests/integration/cases/generics/select/__expected__/dom.large.html new file mode 100644 index 000000000..65de5ef15 --- /dev/null +++ b/tests/integration/cases/generics/select/__expected__/dom.large.html @@ -0,0 +1,19 @@ +
+ + + "Chosen: l" + +
diff --git a/tests/integration/cases/generics/select/__expected__/geometry.initial.json b/tests/integration/cases/generics/select/__expected__/geometry.initial.json new file mode 100644 index 000000000..24f452bc5 --- /dev/null +++ b/tests/integration/cases/generics/select/__expected__/geometry.initial.json @@ -0,0 +1,580 @@ +{ + "version": 1, + "nodes": { + "section[0]": { + "box": [ + 8, + 8, + 151.671875, + 24 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]": { + "box": [ + 8, + 10, + 70.578125, + 20 + ], + "style": { + "display": "inline", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]/#text[0]": { + "box": [ + [ + 8, + 10, + 36.578125, + 20 + ] + ] + }, + "section[0]/label[0]/select[0]": { + "box": [ + 44.578125, + 11, + 34, + 20 + ], + "style": { + "display": "inline-block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "border-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "1px", + "border-right-width": "1px", + "border-bottom-width": "1px", + "border-left-width": "1px", + "border-top-style": "solid", + "border-right-style": "solid", + "border-bottom-style": "solid", + "border-left-style": "solid", + "border-top-color": "rgb(118, 118, 118)", + "border-right-color": "rgb(118, 118, 118)", + "border-bottom-color": "rgb(118, 118, 118)", + "border-left-color": "rgb(118, 118, 118)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgb(239, 239, 239)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "pre", + "vertical-align": "baseline", + "overflow-x": "clip", + "overflow-y": "clip", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "center", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]/select[0]/option[0]": { + "box": [ + 0, + 0, + 0, + 0 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "2px", + "padding-bottom": "1px", + "padding-left": "2px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "nowrap", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "center", + "row-gap": "6.66667px", + "column-gap": "6.66667px", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]/select[0]/option[0]/#text[0]": { + "box": [] + }, + "section[0]/label[0]/select[0]/option[1]": { + "box": [ + 0, + 0, + 0, + 0 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "2px", + "padding-bottom": "1px", + "padding-left": "2px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "nowrap", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "center", + "row-gap": "6.66667px", + "column-gap": "6.66667px", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]/select[0]/option[1]/#text[0]": { + "box": [] + }, + "section[0]/label[0]/select[0]/option[2]": { + "box": [ + 0, + 0, + 0, + 0 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "2px", + "padding-bottom": "1px", + "padding-left": "2px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "nowrap", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "center", + "row-gap": "6.66667px", + "column-gap": "6.66667px", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]/select[0]/option[2]/#text[0]": { + "box": [] + }, + "section[0]/output[0]": { + "box": [ + 78.578125, + 10, + 81.09375, + 20 + ], + "style": { + "display": "inline", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/output[0]/#text[0]": { + "box": [ + [ + 78.578125, + 10, + 81.09375, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/generics/select/__expected__/geometry.large.json b/tests/integration/cases/generics/select/__expected__/geometry.large.json new file mode 100644 index 000000000..b4645fb28 --- /dev/null +++ b/tests/integration/cases/generics/select/__expected__/geometry.large.json @@ -0,0 +1,580 @@ +{ + "version": 1, + "nodes": { + "section[0]": { + "box": [ + 8, + 8, + 141.53125, + 24 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]": { + "box": [ + 8, + 10, + 70.578125, + 20 + ], + "style": { + "display": "inline", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]/#text[0]": { + "box": [ + [ + 8, + 10, + 36.578125, + 20 + ] + ] + }, + "section[0]/label[0]/select[0]": { + "box": [ + 44.578125, + 11, + 34, + 20 + ], + "style": { + "display": "inline-block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "border-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "1px", + "border-right-width": "1px", + "border-bottom-width": "1px", + "border-left-width": "1px", + "border-top-style": "solid", + "border-right-style": "solid", + "border-bottom-style": "solid", + "border-left-style": "solid", + "border-top-color": "rgb(118, 118, 118)", + "border-right-color": "rgb(118, 118, 118)", + "border-bottom-color": "rgb(118, 118, 118)", + "border-left-color": "rgb(118, 118, 118)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgb(239, 239, 239)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "pre", + "vertical-align": "baseline", + "overflow-x": "clip", + "overflow-y": "clip", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "center", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]/select[0]/option[0]": { + "box": [ + 0, + 0, + 0, + 0 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "2px", + "padding-bottom": "1px", + "padding-left": "2px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "nowrap", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "center", + "row-gap": "6.66667px", + "column-gap": "6.66667px", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]/select[0]/option[0]/#text[0]": { + "box": [] + }, + "section[0]/label[0]/select[0]/option[1]": { + "box": [ + 0, + 0, + 0, + 0 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "2px", + "padding-bottom": "1px", + "padding-left": "2px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "nowrap", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "center", + "row-gap": "6.66667px", + "column-gap": "6.66667px", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]/select[0]/option[1]/#text[0]": { + "box": [] + }, + "section[0]/label[0]/select[0]/option[2]": { + "box": [ + 0, + 0, + 0, + 0 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "2px", + "padding-bottom": "1px", + "padding-left": "2px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "nowrap", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "center", + "row-gap": "6.66667px", + "column-gap": "6.66667px", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/label[0]/select[0]/option[2]/#text[0]": { + "box": [] + }, + "section[0]/output[0]": { + "box": [ + 78.578125, + 10, + 70.953125, + 20 + ], + "style": { + "display": "inline", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "section[0]/output[0]/#text[0]": { + "box": [ + [ + 78.578125, + 10, + 70.953125, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/generics/select/__expected__/ssr.default.html b/tests/integration/cases/generics/select/__expected__/ssr.default.html new file mode 100644 index 000000000..a69caddf5 --- /dev/null +++ b/tests/integration/cases/generics/select/__expected__/ssr.default.html @@ -0,0 +1,19 @@ +
+ + + "Chosen: m" + +
diff --git a/tests/integration/cases/generics/select/__expected__/trace.large.json b/tests/integration/cases/generics/select/__expected__/trace.large.json new file mode 100644 index 000000000..84f447777 --- /dev/null +++ b/tests/integration/cases/generics/select/__expected__/trace.large.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "steps": [ + { + "action": "selectOptions getByRole('combobox', { name: 'Size' }) [\"l\"]", + "dom": [ + "
", + " ", + " ", + " \"Chosen: l\"", + " ", + "
" + ], + "aria": [ + "- region \"Size\":", + " - text: Size", + " - combobox \"Size\":", + " - option \"s\"", + " - option \"m\"", + " - option \"l\" [selected]", + " - status: \"Chosen: l\"" + ], + "events": {} + } + ] +} diff --git a/tests/integration/cases/generics/select/__output__/angular/select.ts b/tests/integration/cases/generics/select/__output__/angular/select.ts new file mode 100644 index 000000000..17519af9c --- /dev/null +++ b/tests/integration/cases/generics/select/__output__/angular/select.ts @@ -0,0 +1,73 @@ +import { isPlatformServer } from "@angular/common"; +import { + Component, + Directive, + ElementRef, + type OnInit, + PLATFORM_ID, + Renderer2, + inject, + input, + model, +} from "@angular/core"; + +export interface SelectProps { + label: string; + options: readonly T[]; +} + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-select", + imports: [ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let label = this.label(); + @let options = this.options(); + + `, +}) +export default class Select { + readonly label = input.required(); + readonly options = input.required(); + readonly class = input(); + readonly style = input>(); + readonly value = model.required(); + protected readonly onServer = isPlatformServer(inject(PLATFORM_ID)); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/generics/select/__output__/angular/size-form.ts b/tests/integration/cases/generics/select/__output__/angular/size-form.ts new file mode 100644 index 000000000..32bc0551c --- /dev/null +++ b/tests/integration/cases/generics/select/__output__/angular/size-form.ts @@ -0,0 +1,59 @@ +import Select from "./select"; +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + inject, + input, + signal, +} from "@angular/core"; + +type Size = "s" | "m" | "l"; + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-size-form", + imports: [Select, ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let size = this.size(); +
+ + Chosen: {{ size }} +
+ `, +}) +export default class SizeForm { + readonly class = input(); + readonly style = input>(); + protected readonly sizes: Size[] = ["s", "m", "l"]; + protected readonly size = signal("m"); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/generics/select/__output__/astro/Select.astro b/tests/integration/cases/generics/select/__output__/astro/Select.astro new file mode 100644 index 000000000..2efe86ef2 --- /dev/null +++ b/tests/integration/cases/generics/select/__output__/astro/Select.astro @@ -0,0 +1,30 @@ +--- +export interface SelectProps { + label: string; + options: readonly T[]; +} + +interface Props extends SelectProps { + value: T; + class?: string; + style?: Record; +} + +const { label, options, value, class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + + diff --git a/tests/integration/cases/generics/select/__output__/astro/SizeForm.astro b/tests/integration/cases/generics/select/__output__/astro/SizeForm.astro new file mode 100644 index 000000000..855ce0f2f --- /dev/null +++ b/tests/integration/cases/generics/select/__output__/astro/SizeForm.astro @@ -0,0 +1,27 @@ +--- +import Select from "./Select.astro"; + +type Size = "s" | "m" | "l"; + +interface Props { + class?: string; + style?: Record; +} + +const { class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} + +const sizes: Size[] = ["s", "m", "l"]; +const size = "m" as Size; +--- + +
+ setValue(element.value)}> + {options.map((option) => ( + + ))} + + + ); + }, +); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/generics/select/__output__/qwik/SizeForm.tsx b/tests/integration/cases/generics/select/__output__/qwik/SizeForm.tsx new file mode 100644 index 000000000..ac5057b47 --- /dev/null +++ b/tests/integration/cases/generics/select/__output__/qwik/SizeForm.tsx @@ -0,0 +1,33 @@ +import Select from "./Select"; +import { type CSSProperties, type ClassList, component$, useSignal } from "@qwik.dev/core"; + +type Size = "s" | "m" | "l"; + +const sizes: Size[] = ["s", "m", "l"]; + +export default component$<{ class?: ClassList; style?: CSSProperties }>( + ({ class: className, style, ...attributes }) => { + const size = useSignal("m"); + + return ( +
+ { + setLocalValue({ value: event.target.value }); + onValueChange?.(event.target.value); + }} + > + {options.map((option) => ( + + ))} + + + ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/generics/select/__output__/react/SizeForm.tsx b/tests/integration/cases/generics/select/__output__/react/SizeForm.tsx new file mode 100644 index 000000000..4373d97e4 --- /dev/null +++ b/tests/integration/cases/generics/select/__output__/react/SizeForm.tsx @@ -0,0 +1,31 @@ +import Select from "./Select"; +import { type CSSProperties, useState } from "react"; + +type Size = "s" | "m" | "l"; + +export interface SizeFormAttributes { + className?: string; + style?: CSSProperties; +} + +const sizes: Size[] = ["s", "m", "l"]; + +export default function SizeForm({ className, style, ...attributes }: SizeFormAttributes) { + const [size, setSize] = useState("m"); + + return ( +
+ setValue(event.currentTarget.value)} + ref={(select) => selectOptions(select, value)} + > + + {(option) => ( + + )} + + + + ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} + +/** + * Selects a ` + Chosen: {size()} +
+ ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/generics/select/__output__/svelte/Select.svelte b/tests/integration/cases/generics/select/__output__/svelte/Select.svelte new file mode 100644 index 000000000..dddf47560 --- /dev/null +++ b/tests/integration/cases/generics/select/__output__/svelte/Select.svelte @@ -0,0 +1,36 @@ + + + + + diff --git a/tests/integration/cases/generics/select/__output__/svelte/SizeForm.svelte b/tests/integration/cases/generics/select/__output__/svelte/SizeForm.svelte new file mode 100644 index 000000000..d2eb971ca --- /dev/null +++ b/tests/integration/cases/generics/select/__output__/svelte/SizeForm.svelte @@ -0,0 +1,26 @@ + + + + +
+ + + ); +} diff --git a/tests/integration/cases/types/child-type-import/Form.uf.tsx b/tests/integration/cases/types/child-type-import/Form.uf.tsx new file mode 100644 index 000000000..34e5e17af --- /dev/null +++ b/tests/integration/cases/types/child-type-import/Form.uf.tsx @@ -0,0 +1,11 @@ +import Field from "./Field.uf.tsx"; +import type { FieldProps } from "./Field.uf.tsx"; + +// A props type a child's module exports, imported from the child (ADR-0067). +export default function Form({ field }: { field: FieldProps }) { + return ( +
+ + + ); +} diff --git a/tests/integration/cases/types/child-type-import/__expected__/aria.email.yaml b/tests/integration/cases/types/child-type-import/__expected__/aria.email.yaml new file mode 100644 index 000000000..5f2ccaf6f --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__expected__/aria.email.yaml @@ -0,0 +1,3 @@ +- form "Sign up": + - text: Email + - textbox "Email" diff --git a/tests/integration/cases/types/child-type-import/__expected__/diagnostics.json b/tests/integration/cases/types/child-type-import/__expected__/diagnostics.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__expected__/diagnostics.json @@ -0,0 +1 @@ +[] diff --git a/tests/integration/cases/types/child-type-import/__expected__/dom.email.html b/tests/integration/cases/types/child-type-import/__expected__/dom.email.html new file mode 100644 index 000000000..06d6dd9f3 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__expected__/dom.email.html @@ -0,0 +1,6 @@ +
+ +
diff --git a/tests/integration/cases/types/child-type-import/__expected__/geometry.email.json b/tests/integration/cases/types/child-type-import/__expected__/geometry.email.json new file mode 100644 index 000000000..5470e39f5 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__expected__/geometry.email.json @@ -0,0 +1,249 @@ +{ + "version": 1, + "nodes": { + "form[0]": { + "box": [ + 8, + 8, + 240.375, + 24 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "form[0]/label[0]": { + "box": [ + 8, + 10, + 240.375, + 20 + ], + "style": { + "display": "inline", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "form[0]/label[0]/#text[0]": { + "box": [ + [ + 8, + 10, + 40.375, + 20 + ] + ] + }, + "form[0]/label[0]/input[0]": { + "box": [ + 48.375, + 10, + 200, + 22 + ], + "style": { + "display": "inline-block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "1px", + "padding-right": "2px", + "padding-bottom": "1px", + "padding-left": "2px", + "border-top-width": "2px", + "border-right-width": "2px", + "border-bottom-width": "2px", + "border-left-width": "2px", + "border-top-style": "inset", + "border-right-style": "inset", + "border-bottom-style": "inset", + "border-left-style": "inset", + "border-top-color": "rgb(118, 118, 118)", + "border-right-color": "rgb(118, 118, 118)", + "border-bottom-color": "rgb(118, 118, 118)", + "border-left-color": "rgb(118, 118, 118)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgb(255, 255, 255)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "clip", + "overflow-y": "clip", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + } + } +} diff --git a/tests/integration/cases/types/child-type-import/__expected__/ssr.email.html b/tests/integration/cases/types/child-type-import/__expected__/ssr.email.html new file mode 100644 index 000000000..aa1b22e51 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__expected__/ssr.email.html @@ -0,0 +1,6 @@ +
+ +
diff --git a/tests/integration/cases/types/child-type-import/__output__/angular/field.ts b/tests/integration/cases/types/child-type-import/__output__/angular/field.ts new file mode 100644 index 000000000..21c64a6c2 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/angular/field.ts @@ -0,0 +1,62 @@ +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + inject, + input, +} from "@angular/core"; + +export interface FieldProps { + name: string; + label: string; +} + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-field", + imports: [ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let name = this.name(); + @let label = this.label(); + + `, +}) +export default class Field { + readonly name = input.required(); + readonly label = input.required(); + readonly class = input(); + readonly style = input>(); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/types/child-type-import/__output__/angular/form.ts b/tests/integration/cases/types/child-type-import/__output__/angular/form.ts new file mode 100644 index 000000000..e859ef782 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/angular/form.ts @@ -0,0 +1,54 @@ +import Field from "./field"; +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + inject, + input, +} from "@angular/core"; + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-form", + imports: [Field, ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let field = this.field(); +
+ + + `, +}) +export default class Form { + readonly field = input.required(); + readonly class = input(); + readonly style = input>(); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/types/child-type-import/__output__/astro/Field.astro b/tests/integration/cases/types/child-type-import/__output__/astro/Field.astro new file mode 100644 index 000000000..6cb3ddd0c --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/astro/Field.astro @@ -0,0 +1,26 @@ +--- +export interface FieldProps { + name: string; + label: string; +} + +interface Props extends FieldProps { + class?: string; + style?: Record; +} + +const { name, label, class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + + diff --git a/tests/integration/cases/types/child-type-import/__output__/astro/Form.astro b/tests/integration/cases/types/child-type-import/__output__/astro/Form.astro new file mode 100644 index 000000000..126eb3337 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/astro/Form.astro @@ -0,0 +1,22 @@ +--- +import Field from "./Field.astro"; + +interface Props { + field: FieldProps; + class?: string; + style?: Record; +} + +const { field, class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + +
+ + diff --git a/tests/integration/cases/types/child-type-import/__output__/ir.Field.json b/tests/integration/cases/types/child-type-import/__output__/ir.Field.json new file mode 100644 index 000000000..e87fb157a --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/ir.Field.json @@ -0,0 +1,178 @@ +{ + "irVersion": 1, + "file": "types/child-type-import/Field.uf.tsx", + "components": [ + { + "name": "Field", + "span": { + "start": 66, + "end": 211 + }, + "props": [ + { + "name": "name", + "optional": false, + "type": { + "code": "string", + "span": { + "start": 38, + "end": 44 + } + }, + "binding": "name@98", + "span": { + "start": 32, + "end": 45 + } + }, + { + "name": "label", + "optional": false, + "type": { + "code": "string", + "span": { + "start": 55, + "end": 61 + } + }, + "binding": "label@104", + "span": { + "start": 48, + "end": 62 + } + } + ], + "propsParameter": { + "form": "destructured", + "type": { + "code": "FieldProps", + "span": { + "start": 113, + "end": 123 + } + }, + "span": { + "start": 96, + "end": 123 + } + }, + "types": [ + "FieldProps" + ], + "bindings": [ + { + "id": "name@98", + "name": "name", + "kind": "prop", + "span": { + "start": 98, + "end": 102 + } + }, + { + "id": "label@104", + "name": "label", + "kind": "prop", + "span": { + "start": 104, + "end": 109 + } + } + ], + "setup": [], + "render": { + "kind": "Element", + "tag": "label", + "attributes": [], + "children": [ + { + "kind": "Interpolation", + "value": { + "code": "label", + "span": { + "start": 157, + "end": 162 + }, + "refs": [ + { + "kind": "Binding", + "binding": "label@104", + "span": { + "start": 157, + "end": 162 + } + } + ] + }, + "span": { + "start": 156, + "end": 163 + } + }, + { + "kind": "Element", + "tag": "input", + "attributes": [ + { + "kind": "Bound", + "name": "name", + "value": { + "code": "name", + "span": { + "start": 183, + "end": 187 + }, + "refs": [ + { + "kind": "Binding", + "binding": "name@98", + "span": { + "start": 183, + "end": 187 + } + } + ] + }, + "span": { + "start": 177, + "end": 188 + } + } + ], + "children": [], + "span": { + "start": 170, + "end": 191 + } + } + ], + "span": { + "start": 142, + "end": 204 + } + } + } + ], + "exports": [ + { + "kind": "default", + "name": "default", + "local": "Field", + "span": { + "start": 66, + "end": 211 + } + } + ], + "types": [ + { + "name": "FieldProps", + "exported": true, + "code": "interface FieldProps {\n name: string;\n label: string;\n}", + "span": { + "start": 7, + "end": 64 + } + } + ] +} diff --git a/tests/integration/cases/types/child-type-import/__output__/ir.json b/tests/integration/cases/types/child-type-import/__output__/ir.json new file mode 100644 index 000000000..7a7d89d38 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/ir.json @@ -0,0 +1,221 @@ +{ + "irVersion": 1, + "file": "types/child-type-import/Form.uf.tsx", + "components": [ + { + "name": "Form", + "span": { + "start": 165, + "end": 345 + }, + "props": [ + { + "name": "field", + "optional": false, + "type": { + "code": "FieldProps", + "span": { + "start": 214, + "end": 224 + } + }, + "binding": "field@196", + "span": { + "start": 207, + "end": 224 + } + } + ], + "propsParameter": { + "form": "destructured", + "type": { + "code": "{ field: FieldProps }", + "span": { + "start": 205, + "end": 226 + } + }, + "span": { + "start": 194, + "end": 226 + } + }, + "types": [], + "bindings": [ + { + "id": "field@196", + "name": "field", + "kind": "prop", + "span": { + "start": 196, + "end": 201 + } + } + ], + "setup": [], + "render": { + "kind": "Element", + "tag": "form", + "attributes": [ + { + "kind": "Static", + "name": "aria-label", + "value": "Sign up", + "span": { + "start": 251, + "end": 271 + } + } + ], + "children": [ + { + "kind": "Component", + "component": "Field", + "attributes": [ + { + "kind": "Prop", + "name": "name", + "value": { + "code": "field.name", + "span": { + "start": 292, + "end": 302 + }, + "refs": [ + { + "kind": "Binding", + "binding": "field@196", + "span": { + "start": 292, + "end": 297 + } + } + ] + }, + "span": { + "start": 286, + "end": 303 + } + }, + { + "kind": "Prop", + "name": "label", + "value": { + "code": "field.label", + "span": { + "start": 311, + "end": 322 + }, + "refs": [ + { + "kind": "Binding", + "binding": "field@196", + "span": { + "start": 311, + "end": 316 + } + } + ] + }, + "span": { + "start": 304, + "end": 323 + } + } + ], + "fills": [], + "span": { + "start": 279, + "end": 326 + } + } + ], + "span": { + "start": 245, + "end": 338 + } + } + } + ], + "exports": [ + { + "kind": "default", + "name": "default", + "local": "Form", + "span": { + "start": 165, + "end": 345 + } + } + ], + "types": [], + "imports": [ + { + "specifier": "./Field.uf.tsx", + "file": "Field.uf.tsx", + "api": { + "file": "Field.uf.tsx", + "components": [ + { + "name": "Field", + "export": "default", + "props": [ + { + "name": "name", + "optional": false, + "type": "string" + }, + { + "name": "label", + "optional": false, + "type": "string" + } + ], + "events": [], + "models": [], + "slots": [], + "exposes": [], + "inheritAttrs": true, + "root": "element", + "rootTag": "label" + } + ], + "keys": [] + }, + "names": [ + { + "kind": "Component", + "imported": "default", + "local": "Field", + "span": { + "start": 7, + "end": 12 + } + } + ], + "span": { + "start": 0, + "end": 35 + } + } + ], + "typeImports": [ + { + "specifier": "./Field.uf.tsx", + "names": [ + { + "imported": "FieldProps", + "local": "FieldProps", + "span": { + "start": 50, + "end": 60 + } + } + ], + "span": { + "start": 36, + "end": 85 + } + } + ] +} diff --git a/tests/integration/cases/types/child-type-import/__output__/qwik/Field.tsx b/tests/integration/cases/types/child-type-import/__output__/qwik/Field.tsx new file mode 100644 index 000000000..5880c057c --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/qwik/Field.tsx @@ -0,0 +1,26 @@ +import { type CSSProperties, type ClassList, component$ } from "@qwik.dev/core"; + +export interface FieldProps { + name: string; + label: string; +} + +export default component$( + ({ name, label, class: className, style, ...attributes }) => { + return ( + + ); + }, +); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/child-type-import/__output__/qwik/Form.tsx b/tests/integration/cases/types/child-type-import/__output__/qwik/Form.tsx new file mode 100644 index 000000000..49f421108 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/qwik/Form.tsx @@ -0,0 +1,21 @@ +import Field from "./Field"; +import { type CSSProperties, type ClassList, component$ } from "@qwik.dev/core"; + +export default component$<{ field: FieldProps } & { class?: ClassList; style?: CSSProperties }>( + ({ field, class: className, style, ...attributes }) => { + return ( +
+ + + ); + }, +); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/child-type-import/__output__/react/Field.tsx b/tests/integration/cases/types/child-type-import/__output__/react/Field.tsx new file mode 100644 index 000000000..b76980e6c --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/react/Field.tsx @@ -0,0 +1,35 @@ +import type { CSSProperties } from "react"; + +export interface FieldProps { + name: string; + label: string; +} + +export interface FieldAttributes { + className?: string; + style?: CSSProperties; +} + +export default function Field({ + name, + label, + className, + style, + ...attributes +}: FieldProps & FieldAttributes) { + return ( + + ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/child-type-import/__output__/react/Form.tsx b/tests/integration/cases/types/child-type-import/__output__/react/Form.tsx new file mode 100644 index 000000000..6c9e1d7d2 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/react/Form.tsx @@ -0,0 +1,29 @@ +import Field from "./Field"; +import type { CSSProperties } from "react"; + +export interface FormAttributes { + className?: string; + style?: CSSProperties; +} + +export default function Form({ + field, + className, + style, + ...attributes +}: { field: FieldProps } & FormAttributes) { + return ( +
+ + + ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/child-type-import/__output__/solid/Field.tsx b/tests/integration/cases/types/child-type-import/__output__/solid/Field.tsx new file mode 100644 index 000000000..d1bb2a55d --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/solid/Field.tsx @@ -0,0 +1,22 @@ +import type { JSX } from "solid-js"; + +export interface FieldProps { + name: string; + label: string; +} + +export default function Field(props: FieldProps & { class?: string; style?: JSX.CSSProperties }) { + return ( + + ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/types/child-type-import/__output__/solid/Form.tsx b/tests/integration/cases/types/child-type-import/__output__/solid/Form.tsx new file mode 100644 index 000000000..2063a569d --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/solid/Form.tsx @@ -0,0 +1,19 @@ +import Field from "./Field"; +import type { JSX } from "solid-js"; + +export default function Form( + props: { field: FieldProps } & { class?: string; style?: JSX.CSSProperties }, +) { + return ( +
+ + + ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/types/child-type-import/__output__/svelte/Field.svelte b/tests/integration/cases/types/child-type-import/__output__/svelte/Field.svelte new file mode 100644 index 000000000..c2ce87caf --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/svelte/Field.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/tests/integration/cases/types/child-type-import/__output__/svelte/Form.svelte b/tests/integration/cases/types/child-type-import/__output__/svelte/Form.svelte new file mode 100644 index 000000000..5a1d060bd --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/svelte/Form.svelte @@ -0,0 +1,21 @@ + + + + +
+ + diff --git a/tests/integration/cases/types/child-type-import/__output__/vue/Field.vue b/tests/integration/cases/types/child-type-import/__output__/vue/Field.vue new file mode 100644 index 000000000..4465315a8 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/vue/Field.vue @@ -0,0 +1,12 @@ + + + diff --git a/tests/integration/cases/types/child-type-import/__output__/vue/Form.vue b/tests/integration/cases/types/child-type-import/__output__/vue/Form.vue new file mode 100644 index 000000000..d843fae9e --- /dev/null +++ b/tests/integration/cases/types/child-type-import/__output__/vue/Form.vue @@ -0,0 +1,11 @@ + + + diff --git a/tests/integration/cases/types/child-type-import/__screenshots__/email-chromium-linux.png b/tests/integration/cases/types/child-type-import/__screenshots__/email-chromium-linux.png new file mode 100644 index 000000000..270dcd943 Binary files /dev/null and b/tests/integration/cases/types/child-type-import/__screenshots__/email-chromium-linux.png differ diff --git a/tests/integration/cases/types/child-type-import/case.json b/tests/integration/cases/types/child-type-import/case.json new file mode 100644 index 000000000..7294dc894 --- /dev/null +++ b/tests/integration/cases/types/child-type-import/case.json @@ -0,0 +1,7 @@ +{ + "description": "A parent's prop typed with a type its child's module exports.", + "main": "Form.uf.tsx", + "ssr": { + "email": { "props": { "field": { "name": "email", "label": "Email" } } } + } +} diff --git a/tests/integration/cases/types/child-type-import/child-type-import.test.ts b/tests/integration/cases/types/child-type-import/child-type-import.test.ts new file mode 100644 index 000000000..dde82b34c --- /dev/null +++ b/tests/integration/cases/types/child-type-import/child-type-import.test.ts @@ -0,0 +1,16 @@ +// types/child-type-import: a parent types a prop with a type its child's module exports, imported +// from the child's output (ADR-0067). +import { describeTargets, it, mountScenario } from "@unframework/testing"; +import { expect } from "vitest"; + +import Form from "./Form.uf.tsx"; + +describeTargets("types/child-type-import", () => { + it("passes the typed prop on to the child", async () => { + const view = await mountScenario(Form, "email"); + await view.expectParity("email"); + await expect + .element(view.getByRole("textbox", { name: "Email" })) + .toHaveAttribute("name", "email"); + }); +}); diff --git a/tests/integration/cases/types/exported-types/Button.uf.tsx b/tests/integration/cases/types/exported-types/Button.uf.tsx new file mode 100644 index 000000000..d23ae902a --- /dev/null +++ b/tests/integration/cases/types/exported-types/Button.uf.tsx @@ -0,0 +1,18 @@ +// Every type the module exports is exported by each output (ADR-0068): one the props reach, and +// one no component reaches. +export type ButtonTone = "primary" | "secondary"; + +export type ButtonSize = "small" | "large"; + +export interface ButtonProps { + label: string; + tone?: ButtonTone; +} + +export default function Button({ label, tone = "primary" }: ButtonProps) { + return ( + + ); +} diff --git a/tests/integration/cases/types/exported-types/__expected__/aria.secondary.yaml b/tests/integration/cases/types/exported-types/__expected__/aria.secondary.yaml new file mode 100644 index 000000000..43d43d54c --- /dev/null +++ b/tests/integration/cases/types/exported-types/__expected__/aria.secondary.yaml @@ -0,0 +1 @@ +- button "Cancel" diff --git a/tests/integration/cases/types/exported-types/__expected__/diagnostics.json b/tests/integration/cases/types/exported-types/__expected__/diagnostics.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/tests/integration/cases/types/exported-types/__expected__/diagnostics.json @@ -0,0 +1 @@ +[] diff --git a/tests/integration/cases/types/exported-types/__expected__/dom.secondary.html b/tests/integration/cases/types/exported-types/__expected__/dom.secondary.html new file mode 100644 index 000000000..79d72877b --- /dev/null +++ b/tests/integration/cases/types/exported-types/__expected__/dom.secondary.html @@ -0,0 +1,3 @@ + diff --git a/tests/integration/cases/types/exported-types/__expected__/geometry.secondary.json b/tests/integration/cases/types/exported-types/__expected__/geometry.secondary.json new file mode 100644 index 000000000..ec81d669b --- /dev/null +++ b/tests/integration/cases/types/exported-types/__expected__/geometry.secondary.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "nodes": { + "button[0]": { + "box": [ + 8, + 10, + 59.71875, + 22 + ], + "style": { + "display": "inline-block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "border-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "1px", + "padding-right": "6px", + "padding-bottom": "1px", + "padding-left": "6px", + "border-top-width": "2px", + "border-right-width": "2px", + "border-bottom-width": "2px", + "border-left-width": "2px", + "border-top-style": "outset", + "border-right-style": "outset", + "border-bottom-style": "outset", + "border-left-style": "outset", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgb(239, 239, 239)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "center", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "button[0]/#text[0]": { + "box": [ + [ + 16, + 13, + 43.71875, + 16 + ] + ] + } + } +} diff --git a/tests/integration/cases/types/exported-types/__expected__/ssr.secondary.html b/tests/integration/cases/types/exported-types/__expected__/ssr.secondary.html new file mode 100644 index 000000000..79d72877b --- /dev/null +++ b/tests/integration/cases/types/exported-types/__expected__/ssr.secondary.html @@ -0,0 +1,3 @@ + diff --git a/tests/integration/cases/types/exported-types/__output__/angular/button.ts b/tests/integration/cases/types/exported-types/__output__/angular/button.ts new file mode 100644 index 000000000..cdde86b31 --- /dev/null +++ b/tests/integration/cases/types/exported-types/__output__/angular/button.ts @@ -0,0 +1,67 @@ +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + inject, + input, +} from "@angular/core"; + +export type ButtonTone = "primary" | "secondary"; + +export interface ButtonProps { + label: string; + tone?: ButtonTone; +} + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-button", + imports: [ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let label = this.label(); + @let tone = this.tone(); + + `, +}) +export default class Button { + readonly label = input.required(); + readonly tone = input("primary", { + transform: (value) => (value === undefined ? "primary" : value), + }); + readonly class = input(); + readonly style = input>(); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/types/exported-types/__output__/astro/Button.astro b/tests/integration/cases/types/exported-types/__output__/astro/Button.astro new file mode 100644 index 000000000..cb8a8015c --- /dev/null +++ b/tests/integration/cases/types/exported-types/__output__/astro/Button.astro @@ -0,0 +1,29 @@ +--- +export type ButtonTone = "primary" | "secondary"; + +export interface ButtonProps { + label: string; + tone?: ButtonTone; +} + +interface Props extends ButtonProps { + class?: string; + style?: Record; +} + +const { label, tone = "primary", class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + + diff --git a/tests/integration/cases/types/exported-types/__output__/ir.json b/tests/integration/cases/types/exported-types/__output__/ir.json new file mode 100644 index 000000000..120a373fa --- /dev/null +++ b/tests/integration/cases/types/exported-types/__output__/ir.json @@ -0,0 +1,214 @@ +{ + "irVersion": 1, + "file": "types/exported-types/Button.uf.tsx", + "components": [ + { + "name": "Button", + "span": { + "start": 294, + "end": 473 + }, + "props": [ + { + "name": "label", + "optional": false, + "type": { + "code": "string", + "span": { + "start": 262, + "end": 268 + } + }, + "binding": "label@327", + "span": { + "start": 255, + "end": 269 + } + }, + { + "name": "tone", + "optional": true, + "type": { + "code": "ButtonTone", + "span": { + "start": 279, + "end": 289 + } + }, + "default": { + "code": "\"primary\"", + "span": { + "start": 341, + "end": 350 + }, + "refs": [] + }, + "binding": "tone@334", + "span": { + "start": 272, + "end": 290 + } + } + ], + "propsParameter": { + "form": "destructured", + "type": { + "code": "ButtonProps", + "span": { + "start": 354, + "end": 365 + } + }, + "span": { + "start": 325, + "end": 365 + } + }, + "types": [ + "ButtonTone", + "ButtonProps" + ], + "bindings": [ + { + "id": "label@327", + "name": "label", + "kind": "prop", + "span": { + "start": 327, + "end": 332 + } + }, + { + "id": "tone@334", + "name": "tone", + "kind": "prop", + "span": { + "start": 334, + "end": 338 + } + } + ], + "setup": [], + "render": { + "kind": "Element", + "tag": "button", + "attributes": [ + { + "kind": "Static", + "name": "type", + "value": "button", + "span": { + "start": 392, + "end": 405 + } + }, + { + "kind": "Class", + "items": [ + { + "kind": "Dynamic", + "value": { + "code": "`button button-${tone}`", + "span": { + "start": 413, + "end": 436 + }, + "refs": [ + { + "kind": "Binding", + "binding": "tone@334", + "span": { + "start": 430, + "end": 434 + } + } + ] + }, + "span": { + "start": 413, + "end": 436 + } + } + ], + "span": { + "start": 406, + "end": 437 + } + } + ], + "children": [ + { + "kind": "Interpolation", + "value": { + "code": "label", + "span": { + "start": 446, + "end": 451 + }, + "refs": [ + { + "kind": "Binding", + "binding": "label@327", + "span": { + "start": 446, + "end": 451 + } + } + ] + }, + "span": { + "start": 445, + "end": 452 + } + } + ], + "span": { + "start": 384, + "end": 466 + } + } + } + ], + "exports": [ + { + "kind": "default", + "name": "default", + "local": "Button", + "span": { + "start": 294, + "end": 473 + } + } + ], + "types": [ + { + "name": "ButtonTone", + "exported": true, + "code": "type ButtonTone = \"primary\" | \"secondary\";", + "span": { + "start": 133, + "end": 175 + } + }, + { + "name": "ButtonProps", + "exported": true, + "code": "interface ButtonProps {\n label: string;\n tone?: ButtonTone;\n}", + "span": { + "start": 229, + "end": 292 + } + } + ], + "typeExports": [ + { + "kind": "Declaration", + "name": "ButtonSize", + "code": "type ButtonSize = \"small\" | \"large\";", + "span": { + "start": 184, + "end": 220 + } + } + ] +} diff --git a/tests/integration/cases/types/exported-types/__output__/qwik/Button.tsx b/tests/integration/cases/types/exported-types/__output__/qwik/Button.tsx new file mode 100644 index 000000000..ddc0cd4f4 --- /dev/null +++ b/tests/integration/cases/types/exported-types/__output__/qwik/Button.tsx @@ -0,0 +1,32 @@ +import { type CSSProperties, type ClassList, component$ } from "@qwik.dev/core"; + +export type ButtonTone = "primary" | "secondary"; + +export interface ButtonProps { + label: string; + tone?: ButtonTone; +} + +export default component$( + ({ label, tone = "primary", class: className, style, ...attributes }) => { + return ( + + ); + }, +); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/exported-types/__output__/react/Button.tsx b/tests/integration/cases/types/exported-types/__output__/react/Button.tsx new file mode 100644 index 000000000..b1bfbe821 --- /dev/null +++ b/tests/integration/cases/types/exported-types/__output__/react/Button.tsx @@ -0,0 +1,56 @@ +import type { CSSProperties } from "react"; + +export type ButtonTone = "primary" | "secondary"; + +export interface ButtonProps { + label: string; + tone?: ButtonTone; +} + +export interface ButtonAttributes { + className?: string; + style?: CSSProperties; +} + +export default function Button({ + label, + tone = "primary", + className, + style, + ...attributes +}: ButtonProps & ButtonAttributes) { + return ( + + ); +} + +/** Joins class names, and the keys of an object's truthy entries, into one `className`. */ +function cx(...parts: unknown[]): string { + const names: string[] = []; + for (const part of parts) { + if (typeof part === "string") { + if (part) names.push(part); + } else if (part && typeof part === "object") { + for (const [key, on] of Object.entries(part)) { + if (on) names.push(key); + } + } + } + return names.join(" "); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/exported-types/__output__/solid/Button.tsx b/tests/integration/cases/types/exported-types/__output__/solid/Button.tsx new file mode 100644 index 000000000..3be1f3155 --- /dev/null +++ b/tests/integration/cases/types/exported-types/__output__/solid/Button.tsx @@ -0,0 +1,43 @@ +import { type JSX, mergeProps } from "solid-js"; + +export type ButtonTone = "primary" | "secondary"; + +export interface ButtonProps { + label: string; + tone?: ButtonTone; +} + +export default function Button( + rawProps: ButtonProps & { class?: string; style?: JSX.CSSProperties }, +) { + const props = mergeProps({ tone: "primary" } satisfies Partial, rawProps); + return ( + + ); +} + +/** Joins class names: strings as they are, and the names of an object's truthy entries. */ +function cx(...parts: unknown[]): string { + const names: string[] = []; + for (const part of parts) { + if (typeof part === "string") names.push(part); + else if (part && typeof part === "object") { + for (const [name, on] of Object.entries(part)) if (on) names.push(name); + } + } + return names.join(" "); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/types/exported-types/__output__/svelte/Button.svelte b/tests/integration/cases/types/exported-types/__output__/svelte/Button.svelte new file mode 100644 index 000000000..deaa9c24f --- /dev/null +++ b/tests/integration/cases/types/exported-types/__output__/svelte/Button.svelte @@ -0,0 +1,30 @@ + + + + + diff --git a/tests/integration/cases/types/exported-types/__output__/vue/Button.vue b/tests/integration/cases/types/exported-types/__output__/vue/Button.vue new file mode 100644 index 000000000..aadd181ec --- /dev/null +++ b/tests/integration/cases/types/exported-types/__output__/vue/Button.vue @@ -0,0 +1,16 @@ + + + diff --git a/tests/integration/cases/types/exported-types/__screenshots__/secondary-chromium-linux.png b/tests/integration/cases/types/exported-types/__screenshots__/secondary-chromium-linux.png new file mode 100644 index 000000000..ef09c9266 Binary files /dev/null and b/tests/integration/cases/types/exported-types/__screenshots__/secondary-chromium-linux.png differ diff --git a/tests/integration/cases/types/exported-types/case.json b/tests/integration/cases/types/exported-types/case.json new file mode 100644 index 000000000..6883cb2df --- /dev/null +++ b/tests/integration/cases/types/exported-types/case.json @@ -0,0 +1,6 @@ +{ + "description": "A module that exports its props type, a type the props reach, and a type no component reaches.", + "ssr": { + "secondary": { "props": { "label": "Cancel", "tone": "secondary" } } + } +} diff --git a/tests/integration/cases/types/exported-types/exported-types.test.ts b/tests/integration/cases/types/exported-types/exported-types.test.ts new file mode 100644 index 000000000..60630ac37 --- /dev/null +++ b/tests/integration/cases/types/exported-types/exported-types.test.ts @@ -0,0 +1,16 @@ +// types/exported-types: the types a component's module exports, which its consumers import from +// its output (ADR-0068). +import { describeTargets, it, mountScenario } from "@unframework/testing"; +import { expect } from "vitest"; + +import Button from "./Button.uf.tsx"; + +describeTargets("types/exported-types", () => { + it("renders with its exported props type", async () => { + const view = await mountScenario(Button, "secondary"); + await view.expectParity("secondary"); + await expect + .element(view.getByRole("button", { name: "Cancel" })) + .toHaveClass("button button-secondary"); + }); +}); diff --git a/tests/integration/cases/types/extends-chain/Button.uf.tsx b/tests/integration/cases/types/extends-chain/Button.uf.tsx new file mode 100644 index 000000000..5c1a2e82c --- /dev/null +++ b/tests/integration/cases/types/extends-chain/Button.uf.tsx @@ -0,0 +1,9 @@ +import type { ButtonProps } from "./types"; + +export default function Button({ id, label, disabled = false }: ButtonProps) { + return ( + + ); +} diff --git a/tests/integration/cases/types/extends-chain/__expected__/aria.disabled.yaml b/tests/integration/cases/types/extends-chain/__expected__/aria.disabled.yaml new file mode 100644 index 000000000..afe2f66a6 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__expected__/aria.disabled.yaml @@ -0,0 +1 @@ +- button "Delete" [disabled] diff --git a/tests/integration/cases/types/extends-chain/__expected__/aria.enabled.yaml b/tests/integration/cases/types/extends-chain/__expected__/aria.enabled.yaml new file mode 100644 index 000000000..cdd6d5c45 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__expected__/aria.enabled.yaml @@ -0,0 +1 @@ +- button "Save" diff --git a/tests/integration/cases/types/extends-chain/__expected__/diagnostics.json b/tests/integration/cases/types/extends-chain/__expected__/diagnostics.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__expected__/diagnostics.json @@ -0,0 +1 @@ +[] diff --git a/tests/integration/cases/types/extends-chain/__expected__/dom.disabled.html b/tests/integration/cases/types/extends-chain/__expected__/dom.disabled.html new file mode 100644 index 000000000..b5f9b7c3c --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__expected__/dom.disabled.html @@ -0,0 +1,3 @@ + diff --git a/tests/integration/cases/types/extends-chain/__expected__/dom.enabled.html b/tests/integration/cases/types/extends-chain/__expected__/dom.enabled.html new file mode 100644 index 000000000..540d4ce57 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__expected__/dom.enabled.html @@ -0,0 +1,3 @@ + diff --git a/tests/integration/cases/types/extends-chain/__expected__/geometry.disabled.json b/tests/integration/cases/types/extends-chain/__expected__/geometry.disabled.json new file mode 100644 index 000000000..9c70335be --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__expected__/geometry.disabled.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "nodes": { + "button[0]": { + "box": [ + 8, + 10, + 56.390625, + 22 + ], + "style": { + "display": "inline-block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "border-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "1px", + "padding-right": "6px", + "padding-bottom": "1px", + "padding-left": "6px", + "border-top-width": "2px", + "border-right-width": "2px", + "border-bottom-width": "2px", + "border-left-width": "2px", + "border-top-style": "outset", + "border-right-style": "outset", + "border-bottom-style": "outset", + "border-left-style": "outset", + "border-top-color": "rgba(118, 118, 118, 0.3)", + "border-right-color": "rgba(118, 118, 118, 0.3)", + "border-bottom-color": "rgba(118, 118, 118, 0.3)", + "border-left-color": "rgba(118, 118, 118, 0.3)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgba(16, 16, 16, 0.3)", + "background-color": "rgba(239, 239, 239, 0.3)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "center", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgba(16, 16, 16, 0.3)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "button[0]/#text[0]": { + "box": [ + [ + 16, + 13, + 40.390625, + 16 + ] + ] + } + } +} diff --git a/tests/integration/cases/types/extends-chain/__expected__/geometry.enabled.json b/tests/integration/cases/types/extends-chain/__expected__/geometry.enabled.json new file mode 100644 index 000000000..6a184b7d9 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__expected__/geometry.enabled.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "nodes": { + "button[0]": { + "box": [ + 8, + 10, + 46.84375, + 22 + ], + "style": { + "display": "inline-block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "border-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "1px", + "padding-right": "6px", + "padding-bottom": "1px", + "padding-left": "6px", + "border-top-width": "2px", + "border-right-width": "2px", + "border-bottom-width": "2px", + "border-left-width": "2px", + "border-top-style": "outset", + "border-right-style": "outset", + "border-bottom-style": "outset", + "border-left-style": "outset", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgb(239, 239, 239)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "13.3333px", + "font-weight": "400", + "font-style": "normal", + "line-height": "normal", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "center", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "button[0]/#text[0]": { + "box": [ + [ + 16, + 13, + 30.84375, + 16 + ] + ] + } + } +} diff --git a/tests/integration/cases/types/extends-chain/__expected__/ssr.disabled.html b/tests/integration/cases/types/extends-chain/__expected__/ssr.disabled.html new file mode 100644 index 000000000..b5f9b7c3c --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__expected__/ssr.disabled.html @@ -0,0 +1,3 @@ + diff --git a/tests/integration/cases/types/extends-chain/__expected__/ssr.enabled.html b/tests/integration/cases/types/extends-chain/__expected__/ssr.enabled.html new file mode 100644 index 000000000..540d4ce57 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__expected__/ssr.enabled.html @@ -0,0 +1,3 @@ + diff --git a/tests/integration/cases/types/extends-chain/__output__/angular/base.ts b/tests/integration/cases/types/extends-chain/__output__/angular/base.ts new file mode 100644 index 000000000..8589f6896 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/angular/base.ts @@ -0,0 +1,4 @@ +// The root of the chain, two modules away from the component. +export interface Identified { + id: string; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/angular/button.ts b/tests/integration/cases/types/extends-chain/__output__/angular/button.ts new file mode 100644 index 000000000..06668a999 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/angular/button.ts @@ -0,0 +1,64 @@ +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + inject, + input, +} from "@angular/core"; + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-button", + imports: [ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let disabled = this.disabled(); + @let label = this.label(); + @let id = this.id(); + + `, +}) +export default class Button { + readonly disabled = input(false, { + transform: (value) => (value === undefined ? false : value), + }); + readonly label = input.required(); + readonly id = input.required(); + readonly class = input(); + readonly style = input>(); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/angular/types.ts b/tests/integration/cases/types/extends-chain/__output__/angular/types.ts new file mode 100644 index 000000000..f6e125b61 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/angular/types.ts @@ -0,0 +1,9 @@ +import type { Identified } from "./base"; + +export interface Labelled extends Identified { + label: string; +} + +export interface ButtonProps extends Labelled { + disabled?: boolean; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/astro/Button.astro b/tests/integration/cases/types/extends-chain/__output__/astro/Button.astro new file mode 100644 index 000000000..fd211de47 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/astro/Button.astro @@ -0,0 +1,24 @@ +--- +interface Props extends ButtonProps { + class?: string; + style?: Record; +} + +const { id, label, disabled = false, class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + + diff --git a/tests/integration/cases/types/extends-chain/__output__/astro/base.ts b/tests/integration/cases/types/extends-chain/__output__/astro/base.ts new file mode 100644 index 000000000..8589f6896 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/astro/base.ts @@ -0,0 +1,4 @@ +// The root of the chain, two modules away from the component. +export interface Identified { + id: string; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/astro/types.ts b/tests/integration/cases/types/extends-chain/__output__/astro/types.ts new file mode 100644 index 000000000..f6e125b61 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/astro/types.ts @@ -0,0 +1,9 @@ +import type { Identified } from "./base"; + +export interface Labelled extends Identified { + label: string; +} + +export interface ButtonProps extends Labelled { + disabled?: boolean; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/ir.json b/tests/integration/cases/types/extends-chain/__output__/ir.json new file mode 100644 index 000000000..db5eb925e --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/ir.json @@ -0,0 +1,266 @@ +{ + "irVersion": 1, + "file": "types/extends-chain/Button.uf.tsx", + "components": [ + { + "name": "Button", + "span": { + "start": 45, + "end": 224 + }, + "props": [ + { + "name": "disabled", + "optional": true, + "type": { + "code": "boolean", + "span": { + "start": 109, + "end": 120 + } + }, + "default": { + "code": "false", + "span": { + "start": 100, + "end": 105 + }, + "refs": [] + }, + "binding": "disabled@89", + "declaredBy": { + "file": "types/extends-chain/types.ts", + "declaration": "ButtonProps", + "span": { + "start": 160, + "end": 179 + } + }, + "span": { + "start": 109, + "end": 120 + } + }, + { + "name": "label", + "optional": false, + "type": { + "code": "string", + "span": { + "start": 109, + "end": 120 + } + }, + "binding": "label@82", + "declaredBy": { + "file": "types/extends-chain/types.ts", + "declaration": "Labelled", + "span": { + "start": 92, + "end": 106 + } + }, + "span": { + "start": 109, + "end": 120 + } + }, + { + "name": "id", + "optional": false, + "type": { + "code": "string", + "span": { + "start": 109, + "end": 120 + } + }, + "binding": "id@78", + "declaredBy": { + "file": "types/extends-chain/base.ts", + "declaration": "Identified", + "span": { + "start": 95, + "end": 106 + } + }, + "span": { + "start": 109, + "end": 120 + } + } + ], + "propsParameter": { + "form": "destructured", + "type": { + "code": "ButtonProps", + "span": { + "start": 109, + "end": 120 + } + }, + "span": { + "start": 76, + "end": 120 + } + }, + "types": [], + "bindings": [ + { + "id": "id@78", + "name": "id", + "kind": "prop", + "span": { + "start": 78, + "end": 80 + } + }, + { + "id": "label@82", + "name": "label", + "kind": "prop", + "span": { + "start": 82, + "end": 87 + } + }, + { + "id": "disabled@89", + "name": "disabled", + "kind": "prop", + "span": { + "start": 89, + "end": 97 + } + } + ], + "setup": [], + "render": { + "kind": "Element", + "tag": "button", + "attributes": [ + { + "kind": "Bound", + "name": "id", + "value": { + "code": "id", + "span": { + "start": 151, + "end": 153 + }, + "refs": [ + { + "kind": "Binding", + "binding": "id@78", + "span": { + "start": 151, + "end": 153 + } + } + ] + }, + "span": { + "start": 147, + "end": 154 + } + }, + { + "kind": "Static", + "name": "type", + "value": "button", + "span": { + "start": 155, + "end": 168 + } + }, + { + "kind": "Bound", + "name": "disabled", + "value": { + "code": "disabled", + "span": { + "start": 179, + "end": 187 + }, + "refs": [ + { + "kind": "Binding", + "binding": "disabled@89", + "span": { + "start": 179, + "end": 187 + } + } + ] + }, + "span": { + "start": 169, + "end": 188 + } + } + ], + "children": [ + { + "kind": "Interpolation", + "value": { + "code": "label", + "span": { + "start": 197, + "end": 202 + }, + "refs": [ + { + "kind": "Binding", + "binding": "label@82", + "span": { + "start": 197, + "end": 202 + } + } + ] + }, + "span": { + "start": 196, + "end": 203 + } + } + ], + "span": { + "start": 139, + "end": 217 + } + } + } + ], + "exports": [ + { + "kind": "default", + "name": "default", + "local": "Button", + "span": { + "start": 45, + "end": 224 + } + } + ], + "types": [], + "typeImports": [ + { + "specifier": "./types", + "names": [ + { + "imported": "ButtonProps", + "local": "ButtonProps", + "span": { + "start": 14, + "end": 25 + } + } + ], + "span": { + "start": 0, + "end": 43 + } + } + ] +} diff --git a/tests/integration/cases/types/extends-chain/__output__/qwik/Button.tsx b/tests/integration/cases/types/extends-chain/__output__/qwik/Button.tsx new file mode 100644 index 000000000..422b659e5 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/qwik/Button.tsx @@ -0,0 +1,27 @@ +import { type CSSProperties, type ClassList, component$ } from "@qwik.dev/core"; + +export default component$( + ({ id, label, disabled = false, class: className, style, ...attributes }) => { + return ( + + ); + }, +); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/qwik/base.ts b/tests/integration/cases/types/extends-chain/__output__/qwik/base.ts new file mode 100644 index 000000000..8589f6896 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/qwik/base.ts @@ -0,0 +1,4 @@ +// The root of the chain, two modules away from the component. +export interface Identified { + id: string; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/qwik/types.ts b/tests/integration/cases/types/extends-chain/__output__/qwik/types.ts new file mode 100644 index 000000000..f6e125b61 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/qwik/types.ts @@ -0,0 +1,9 @@ +import type { Identified } from "./base"; + +export interface Labelled extends Identified { + label: string; +} + +export interface ButtonProps extends Labelled { + disabled?: boolean; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/react/Button.tsx b/tests/integration/cases/types/extends-chain/__output__/react/Button.tsx new file mode 100644 index 000000000..1bf2ecf9f --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/react/Button.tsx @@ -0,0 +1,37 @@ +import type { CSSProperties } from "react"; + +export interface ButtonAttributes { + className?: string; + style?: CSSProperties; +} + +export default function Button({ + id, + label, + disabled = false, + className, + style, + ...attributes +}: ButtonProps & ButtonAttributes) { + return ( + + ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/react/base.ts b/tests/integration/cases/types/extends-chain/__output__/react/base.ts new file mode 100644 index 000000000..8589f6896 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/react/base.ts @@ -0,0 +1,4 @@ +// The root of the chain, two modules away from the component. +export interface Identified { + id: string; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/react/types.ts b/tests/integration/cases/types/extends-chain/__output__/react/types.ts new file mode 100644 index 000000000..f6e125b61 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/react/types.ts @@ -0,0 +1,9 @@ +import type { Identified } from "./base"; + +export interface Labelled extends Identified { + label: string; +} + +export interface ButtonProps extends Labelled { + disabled?: boolean; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/solid/Button.tsx b/tests/integration/cases/types/extends-chain/__output__/solid/Button.tsx new file mode 100644 index 000000000..1137b7139 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/solid/Button.tsx @@ -0,0 +1,26 @@ +import { type JSX, mergeProps } from "solid-js"; + +export default function Button( + rawProps: ButtonProps & { class?: string; style?: JSX.CSSProperties }, +) { + const props = mergeProps({ disabled: false } satisfies Partial, rawProps); + return ( + + ); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/solid/base.ts b/tests/integration/cases/types/extends-chain/__output__/solid/base.ts new file mode 100644 index 000000000..8589f6896 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/solid/base.ts @@ -0,0 +1,4 @@ +// The root of the chain, two modules away from the component. +export interface Identified { + id: string; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/solid/types.ts b/tests/integration/cases/types/extends-chain/__output__/solid/types.ts new file mode 100644 index 000000000..f6e125b61 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/solid/types.ts @@ -0,0 +1,9 @@ +import type { Identified } from "./base"; + +export interface Labelled extends Identified { + label: string; +} + +export interface ButtonProps extends Labelled { + disabled?: boolean; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/svelte/Button.svelte b/tests/integration/cases/types/extends-chain/__output__/svelte/Button.svelte new file mode 100644 index 000000000..44036258c --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/svelte/Button.svelte @@ -0,0 +1,25 @@ + + + + + diff --git a/tests/integration/cases/types/extends-chain/__output__/svelte/base.ts b/tests/integration/cases/types/extends-chain/__output__/svelte/base.ts new file mode 100644 index 000000000..8589f6896 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/svelte/base.ts @@ -0,0 +1,4 @@ +// The root of the chain, two modules away from the component. +export interface Identified { + id: string; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/svelte/types.ts b/tests/integration/cases/types/extends-chain/__output__/svelte/types.ts new file mode 100644 index 000000000..f6e125b61 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/svelte/types.ts @@ -0,0 +1,9 @@ +import type { Identified } from "./base"; + +export interface Labelled extends Identified { + label: string; +} + +export interface ButtonProps extends Labelled { + disabled?: boolean; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/vue/Button.vue b/tests/integration/cases/types/extends-chain/__output__/vue/Button.vue new file mode 100644 index 000000000..80eb7e79e --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/vue/Button.vue @@ -0,0 +1,9 @@ + + + diff --git a/tests/integration/cases/types/extends-chain/__output__/vue/base.ts b/tests/integration/cases/types/extends-chain/__output__/vue/base.ts new file mode 100644 index 000000000..8589f6896 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/vue/base.ts @@ -0,0 +1,4 @@ +// The root of the chain, two modules away from the component. +export interface Identified { + id: string; +} diff --git a/tests/integration/cases/types/extends-chain/__output__/vue/types.ts b/tests/integration/cases/types/extends-chain/__output__/vue/types.ts new file mode 100644 index 000000000..f6e125b61 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/__output__/vue/types.ts @@ -0,0 +1,9 @@ +import type { Identified } from "./base"; + +export interface Labelled extends Identified { + label: string; +} + +export interface ButtonProps extends Labelled { + disabled?: boolean; +} diff --git a/tests/integration/cases/types/extends-chain/__screenshots__/disabled-chromium-linux.png b/tests/integration/cases/types/extends-chain/__screenshots__/disabled-chromium-linux.png new file mode 100644 index 000000000..d57e50907 Binary files /dev/null and b/tests/integration/cases/types/extends-chain/__screenshots__/disabled-chromium-linux.png differ diff --git a/tests/integration/cases/types/extends-chain/__screenshots__/enabled-chromium-linux.png b/tests/integration/cases/types/extends-chain/__screenshots__/enabled-chromium-linux.png new file mode 100644 index 000000000..daf5ca880 Binary files /dev/null and b/tests/integration/cases/types/extends-chain/__screenshots__/enabled-chromium-linux.png differ diff --git a/tests/integration/cases/types/extends-chain/base.ts b/tests/integration/cases/types/extends-chain/base.ts new file mode 100644 index 000000000..8589f6896 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/base.ts @@ -0,0 +1,4 @@ +// The root of the chain, two modules away from the component. +export interface Identified { + id: string; +} diff --git a/tests/integration/cases/types/extends-chain/case.json b/tests/integration/cases/types/extends-chain/case.json new file mode 100644 index 000000000..9c18e343f --- /dev/null +++ b/tests/integration/cases/types/extends-chain/case.json @@ -0,0 +1,7 @@ +{ + "description": "A props interface that extends another, which extends a third in another module.", + "ssr": { + "enabled": { "props": { "id": "save", "label": "Save" } }, + "disabled": { "props": { "id": "delete", "label": "Delete", "disabled": true } } + } +} diff --git a/tests/integration/cases/types/extends-chain/extends-chain.test.ts b/tests/integration/cases/types/extends-chain/extends-chain.test.ts new file mode 100644 index 000000000..8c9a35225 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/extends-chain.test.ts @@ -0,0 +1,21 @@ +// types/extends-chain: props whose interface extends another across two modules (ADR-0067): every +// member of the chain is a prop. +import { describeTargets, it, mountScenario } from "@unframework/testing"; +import { expect } from "vitest"; + +import Button from "./Button.uf.tsx"; + +describeTargets("types/extends-chain", () => { + it("renders every member of the chain", async () => { + const view = await mountScenario(Button, "enabled"); + await view.expectParity("enabled"); + await expect.element(view.getByRole("button", { name: "Save" })).toHaveAttribute("id", "save"); + await expect.element(view.getByRole("button", { name: "Save" })).toBeEnabled(); + }); + + it("renders the inherited props with its own", async () => { + const view = await mountScenario(Button, "disabled"); + await view.expectParity("disabled"); + await expect.element(view.getByRole("button", { name: "Delete" })).toBeDisabled(); + }); +}); diff --git a/tests/integration/cases/types/extends-chain/types.ts b/tests/integration/cases/types/extends-chain/types.ts new file mode 100644 index 000000000..f6e125b61 --- /dev/null +++ b/tests/integration/cases/types/extends-chain/types.ts @@ -0,0 +1,9 @@ +import type { Identified } from "./base"; + +export interface Labelled extends Identified { + label: string; +} + +export interface ButtonProps extends Labelled { + disabled?: boolean; +} diff --git a/tests/integration/cases/types/imported-interface/Badge.uf.tsx b/tests/integration/cases/types/imported-interface/Badge.uf.tsx new file mode 100644 index 000000000..0c4a10dee --- /dev/null +++ b/tests/integration/cases/types/imported-interface/Badge.uf.tsx @@ -0,0 +1,5 @@ +import type { BadgeProps } from "./types"; + +export default function Badge({ label, tone = "info" }: BadgeProps) { + return {label}; +} diff --git a/tests/integration/cases/types/imported-interface/__expected__/aria.danger.yaml b/tests/integration/cases/types/imported-interface/__expected__/aria.danger.yaml new file mode 100644 index 000000000..6e926934e --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__expected__/aria.danger.yaml @@ -0,0 +1 @@ +- text: Overdue diff --git a/tests/integration/cases/types/imported-interface/__expected__/aria.default-tone.yaml b/tests/integration/cases/types/imported-interface/__expected__/aria.default-tone.yaml new file mode 100644 index 000000000..52818f623 --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__expected__/aria.default-tone.yaml @@ -0,0 +1 @@ +- text: New diff --git a/tests/integration/cases/types/imported-interface/__expected__/diagnostics.json b/tests/integration/cases/types/imported-interface/__expected__/diagnostics.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__expected__/diagnostics.json @@ -0,0 +1 @@ +[] diff --git a/tests/integration/cases/types/imported-interface/__expected__/dom.danger.html b/tests/integration/cases/types/imported-interface/__expected__/dom.danger.html new file mode 100644 index 000000000..f8db5127a --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__expected__/dom.danger.html @@ -0,0 +1,3 @@ + + "Overdue" + diff --git a/tests/integration/cases/types/imported-interface/__expected__/dom.default-tone.html b/tests/integration/cases/types/imported-interface/__expected__/dom.default-tone.html new file mode 100644 index 000000000..667537a5a --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__expected__/dom.default-tone.html @@ -0,0 +1,3 @@ + + "New" + diff --git a/tests/integration/cases/types/imported-interface/__expected__/geometry.danger.json b/tests/integration/cases/types/imported-interface/__expected__/geometry.danger.json new file mode 100644 index 000000000..912d22b9c --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__expected__/geometry.danger.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "nodes": { + "span[0]": { + "box": [ + 8, + 10, + 64.59375, + 20 + ], + "style": { + "display": "inline", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "span[0]/#text[0]": { + "box": [ + [ + 8, + 10, + 64.59375, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/types/imported-interface/__expected__/geometry.default-tone.json b/tests/integration/cases/types/imported-interface/__expected__/geometry.default-tone.json new file mode 100644 index 000000000..48dbbdaf3 --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__expected__/geometry.default-tone.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "nodes": { + "span[0]": { + "box": [ + 8, + 10, + 34.1875, + 20 + ], + "style": { + "display": "inline", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "span[0]/#text[0]": { + "box": [ + [ + 8, + 10, + 34.1875, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/types/imported-interface/__expected__/ssr.danger.html b/tests/integration/cases/types/imported-interface/__expected__/ssr.danger.html new file mode 100644 index 000000000..f8db5127a --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__expected__/ssr.danger.html @@ -0,0 +1,3 @@ + + "Overdue" + diff --git a/tests/integration/cases/types/imported-interface/__expected__/ssr.default-tone.html b/tests/integration/cases/types/imported-interface/__expected__/ssr.default-tone.html new file mode 100644 index 000000000..667537a5a --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__expected__/ssr.default-tone.html @@ -0,0 +1,3 @@ + + "New" + diff --git a/tests/integration/cases/types/imported-interface/__output__/angular/badge.ts b/tests/integration/cases/types/imported-interface/__output__/angular/badge.ts new file mode 100644 index 000000000..77930b73e --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/angular/badge.ts @@ -0,0 +1,59 @@ +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + inject, + input, +} from "@angular/core"; + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-badge", + imports: [ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let label = this.label(); + @let tone = this.tone(); + {{ label }} + `, +}) +export default class Badge { + readonly label = input.required(); + readonly tone = input<"info" | "danger", "info" | "danger" | undefined>("info", { + transform: (value) => (value === undefined ? "info" : value), + }); + readonly class = input(); + readonly style = input>(); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/types/imported-interface/__output__/angular/types.ts b/tests/integration/cases/types/imported-interface/__output__/angular/types.ts new file mode 100644 index 000000000..87fc7b9bb --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/angular/types.ts @@ -0,0 +1,6 @@ +// The props of `Badge`, declared in a module of their own, as a design system declares them. +export interface BadgeProps { + /** The text the badge shows. */ + label: string; + tone?: "info" | "danger"; +} diff --git a/tests/integration/cases/types/imported-interface/__output__/astro/Badge.astro b/tests/integration/cases/types/imported-interface/__output__/astro/Badge.astro new file mode 100644 index 000000000..82464c610 --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/astro/Badge.astro @@ -0,0 +1,21 @@ +--- +interface Props extends BadgeProps { + class?: string; + style?: Record; +} + +const { label, tone = "info", class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + +{label} diff --git a/tests/integration/cases/types/imported-interface/__output__/astro/types.ts b/tests/integration/cases/types/imported-interface/__output__/astro/types.ts new file mode 100644 index 000000000..87fc7b9bb --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/astro/types.ts @@ -0,0 +1,6 @@ +// The props of `Badge`, declared in a module of their own, as a design system declares them. +export interface BadgeProps { + /** The text the badge shows. */ + label: string; + tone?: "info" | "danger"; +} diff --git a/tests/integration/cases/types/imported-interface/__output__/ir.json b/tests/integration/cases/types/imported-interface/__output__/ir.json new file mode 100644 index 000000000..16db57d27 --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/ir.json @@ -0,0 +1,207 @@ +{ + "irVersion": 1, + "file": "types/imported-interface/Badge.uf.tsx", + "components": [ + { + "name": "Badge", + "span": { + "start": 44, + "end": 176 + }, + "props": [ + { + "name": "label", + "optional": false, + "type": { + "code": "string", + "span": { + "start": 100, + "end": 110 + } + }, + "binding": "label@76", + "declaredBy": { + "file": "types/imported-interface/types.ts", + "declaration": "BadgeProps", + "span": { + "start": 161, + "end": 175 + } + }, + "span": { + "start": 100, + "end": 110 + } + }, + { + "name": "tone", + "optional": true, + "type": { + "code": "\"info\" | \"danger\"", + "span": { + "start": 100, + "end": 110 + } + }, + "default": { + "code": "\"info\"", + "span": { + "start": 90, + "end": 96 + }, + "refs": [] + }, + "binding": "tone@83", + "declaredBy": { + "file": "types/imported-interface/types.ts", + "declaration": "BadgeProps", + "span": { + "start": 178, + "end": 203 + } + }, + "span": { + "start": 100, + "end": 110 + } + } + ], + "propsParameter": { + "form": "destructured", + "type": { + "code": "BadgeProps", + "span": { + "start": 100, + "end": 110 + } + }, + "span": { + "start": 74, + "end": 110 + } + }, + "types": [], + "bindings": [ + { + "id": "label@76", + "name": "label", + "kind": "prop", + "span": { + "start": 76, + "end": 81 + } + }, + { + "id": "tone@83", + "name": "tone", + "kind": "prop", + "span": { + "start": 83, + "end": 87 + } + } + ], + "setup": [], + "render": { + "kind": "Element", + "tag": "span", + "attributes": [ + { + "kind": "Class", + "items": [ + { + "kind": "Dynamic", + "value": { + "code": "`badge badge-${tone}`", + "span": { + "start": 136, + "end": 157 + }, + "refs": [ + { + "kind": "Binding", + "binding": "tone@83", + "span": { + "start": 151, + "end": 155 + } + } + ] + }, + "span": { + "start": 136, + "end": 157 + } + } + ], + "span": { + "start": 129, + "end": 158 + } + } + ], + "children": [ + { + "kind": "Interpolation", + "value": { + "code": "label", + "span": { + "start": 160, + "end": 165 + }, + "refs": [ + { + "kind": "Binding", + "binding": "label@76", + "span": { + "start": 160, + "end": 165 + } + } + ] + }, + "span": { + "start": 159, + "end": 166 + } + } + ], + "span": { + "start": 123, + "end": 173 + } + } + } + ], + "exports": [ + { + "kind": "default", + "name": "default", + "local": "Badge", + "span": { + "start": 44, + "end": 176 + } + } + ], + "types": [], + "typeImports": [ + { + "specifier": "./types", + "names": [ + { + "imported": "BadgeProps", + "local": "BadgeProps", + "span": { + "start": 14, + "end": 24 + } + } + ], + "span": { + "start": 0, + "end": 42 + } + } + ] +} diff --git a/tests/integration/cases/types/imported-interface/__output__/qwik/Badge.tsx b/tests/integration/cases/types/imported-interface/__output__/qwik/Badge.tsx new file mode 100644 index 000000000..f1f6359e9 --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/qwik/Badge.tsx @@ -0,0 +1,24 @@ +import { type CSSProperties, type ClassList, component$ } from "@qwik.dev/core"; + +export default component$( + ({ label, tone = "info", class: className, style, ...attributes }) => { + return ( + + {label} + + ); + }, +); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/imported-interface/__output__/qwik/types.ts b/tests/integration/cases/types/imported-interface/__output__/qwik/types.ts new file mode 100644 index 000000000..87fc7b9bb --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/qwik/types.ts @@ -0,0 +1,6 @@ +// The props of `Badge`, declared in a module of their own, as a design system declares them. +export interface BadgeProps { + /** The text the badge shows. */ + label: string; + tone?: "info" | "danger"; +} diff --git a/tests/integration/cases/types/imported-interface/__output__/react/Badge.tsx b/tests/integration/cases/types/imported-interface/__output__/react/Badge.tsx new file mode 100644 index 000000000..999c33653 --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/react/Badge.tsx @@ -0,0 +1,48 @@ +import type { CSSProperties } from "react"; + +export interface BadgeAttributes { + className?: string; + style?: CSSProperties; +} + +export default function Badge({ + label, + tone = "info", + className, + style, + ...attributes +}: BadgeProps & BadgeAttributes) { + return ( + + {label} + + ); +} + +/** Joins class names, and the keys of an object's truthy entries, into one `className`. */ +function cx(...parts: unknown[]): string { + const names: string[] = []; + for (const part of parts) { + if (typeof part === "string") { + if (part) names.push(part); + } else if (part && typeof part === "object") { + for (const [key, on] of Object.entries(part)) { + if (on) names.push(key); + } + } + } + return names.join(" "); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/imported-interface/__output__/react/types.ts b/tests/integration/cases/types/imported-interface/__output__/react/types.ts new file mode 100644 index 000000000..87fc7b9bb --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/react/types.ts @@ -0,0 +1,6 @@ +// The props of `Badge`, declared in a module of their own, as a design system declares them. +export interface BadgeProps { + /** The text the badge shows. */ + label: string; + tone?: "info" | "danger"; +} diff --git a/tests/integration/cases/types/imported-interface/__output__/solid/Badge.tsx b/tests/integration/cases/types/imported-interface/__output__/solid/Badge.tsx new file mode 100644 index 000000000..2041257c3 --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/solid/Badge.tsx @@ -0,0 +1,35 @@ +import { type JSX, mergeProps } from "solid-js"; + +export default function Badge( + rawProps: BadgeProps & { class?: string; style?: JSX.CSSProperties }, +) { + const props = mergeProps({ tone: "info" } satisfies Partial, rawProps); + return ( + + {props.label} + + ); +} + +/** Joins class names: strings as they are, and the names of an object's truthy entries. */ +function cx(...parts: unknown[]): string { + const names: string[] = []; + for (const part of parts) { + if (typeof part === "string") names.push(part); + else if (part && typeof part === "object") { + for (const [name, on] of Object.entries(part)) if (on) names.push(name); + } + } + return names.join(" "); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/types/imported-interface/__output__/solid/types.ts b/tests/integration/cases/types/imported-interface/__output__/solid/types.ts new file mode 100644 index 000000000..87fc7b9bb --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/solid/types.ts @@ -0,0 +1,6 @@ +// The props of `Badge`, declared in a module of their own, as a design system declares them. +export interface BadgeProps { + /** The text the badge shows. */ + label: string; + tone?: "info" | "danger"; +} diff --git a/tests/integration/cases/types/imported-interface/__output__/svelte/Badge.svelte b/tests/integration/cases/types/imported-interface/__output__/svelte/Badge.svelte new file mode 100644 index 000000000..d3553083c --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/svelte/Badge.svelte @@ -0,0 +1,22 @@ + + + + +{label} diff --git a/tests/integration/cases/types/imported-interface/__output__/svelte/types.ts b/tests/integration/cases/types/imported-interface/__output__/svelte/types.ts new file mode 100644 index 000000000..87fc7b9bb --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/svelte/types.ts @@ -0,0 +1,6 @@ +// The props of `Badge`, declared in a module of their own, as a design system declares them. +export interface BadgeProps { + /** The text the badge shows. */ + label: string; + tone?: "info" | "danger"; +} diff --git a/tests/integration/cases/types/imported-interface/__output__/vue/Badge.vue b/tests/integration/cases/types/imported-interface/__output__/vue/Badge.vue new file mode 100644 index 000000000..6cefb2a21 --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/vue/Badge.vue @@ -0,0 +1,9 @@ + + + diff --git a/tests/integration/cases/types/imported-interface/__output__/vue/types.ts b/tests/integration/cases/types/imported-interface/__output__/vue/types.ts new file mode 100644 index 000000000..87fc7b9bb --- /dev/null +++ b/tests/integration/cases/types/imported-interface/__output__/vue/types.ts @@ -0,0 +1,6 @@ +// The props of `Badge`, declared in a module of their own, as a design system declares them. +export interface BadgeProps { + /** The text the badge shows. */ + label: string; + tone?: "info" | "danger"; +} diff --git a/tests/integration/cases/types/imported-interface/__screenshots__/danger-chromium-linux.png b/tests/integration/cases/types/imported-interface/__screenshots__/danger-chromium-linux.png new file mode 100644 index 000000000..cefb1a0f1 Binary files /dev/null and b/tests/integration/cases/types/imported-interface/__screenshots__/danger-chromium-linux.png differ diff --git a/tests/integration/cases/types/imported-interface/__screenshots__/default-tone-chromium-linux.png b/tests/integration/cases/types/imported-interface/__screenshots__/default-tone-chromium-linux.png new file mode 100644 index 000000000..7cac102a3 Binary files /dev/null and b/tests/integration/cases/types/imported-interface/__screenshots__/default-tone-chromium-linux.png differ diff --git a/tests/integration/cases/types/imported-interface/case.json b/tests/integration/cases/types/imported-interface/case.json new file mode 100644 index 000000000..7bb55cf4f --- /dev/null +++ b/tests/integration/cases/types/imported-interface/case.json @@ -0,0 +1,7 @@ +{ + "description": "A props interface imported from a `.ts` module, with an optional member and its default.", + "ssr": { + "default-tone": { "props": { "label": "New" } }, + "danger": { "props": { "label": "Overdue", "tone": "danger" } } + } +} diff --git a/tests/integration/cases/types/imported-interface/imported-interface.test.ts b/tests/integration/cases/types/imported-interface/imported-interface.test.ts new file mode 100644 index 000000000..84fe71aad --- /dev/null +++ b/tests/integration/cases/types/imported-interface/imported-interface.test.ts @@ -0,0 +1,20 @@ +// types/imported-interface: a props interface imported from a `.ts` module (ADR-0067). Each output +// imports it by name, and its optional member takes the source's default. +import { describeTargets, it, mountScenario } from "@unframework/testing"; +import { expect } from "vitest"; + +import Badge from "./Badge.uf.tsx"; + +describeTargets("types/imported-interface", () => { + it("renders the label with the default tone", async () => { + const view = await mountScenario(Badge, "default-tone"); + await view.expectParity("default-tone"); + await expect.element(view.getByText("New")).toHaveClass("badge-info"); + }); + + it("renders the tone it is given", async () => { + const view = await mountScenario(Badge, "danger"); + await view.expectParity("danger"); + await expect.element(view.getByText("Overdue")).toHaveClass("badge-danger"); + }); +}); diff --git a/tests/integration/cases/types/imported-interface/types.ts b/tests/integration/cases/types/imported-interface/types.ts new file mode 100644 index 000000000..87fc7b9bb --- /dev/null +++ b/tests/integration/cases/types/imported-interface/types.ts @@ -0,0 +1,6 @@ +// The props of `Badge`, declared in a module of their own, as a design system declares them. +export interface BadgeProps { + /** The text the badge shows. */ + label: string; + tone?: "info" | "danger"; +} diff --git a/tests/integration/cases/types/intersection-props/Chip.uf.tsx b/tests/integration/cases/types/intersection-props/Chip.uf.tsx new file mode 100644 index 000000000..39b54558b --- /dev/null +++ b/tests/integration/cases/types/intersection-props/Chip.uf.tsx @@ -0,0 +1,7 @@ +import type { Sized, Toned } from "./types"; + +type ChipProps = Sized & Toned & { label: string }; + +export default function Chip({ size, tone = "neutral", label }: ChipProps) { + return {label}; +} diff --git a/tests/integration/cases/types/intersection-props/__expected__/aria.accent.yaml b/tests/integration/cases/types/intersection-props/__expected__/aria.accent.yaml new file mode 100644 index 000000000..49d76ddff --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__expected__/aria.accent.yaml @@ -0,0 +1 @@ +- text: Live diff --git a/tests/integration/cases/types/intersection-props/__expected__/aria.small.yaml b/tests/integration/cases/types/intersection-props/__expected__/aria.small.yaml new file mode 100644 index 000000000..48358dcd0 --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__expected__/aria.small.yaml @@ -0,0 +1 @@ +- text: Draft diff --git a/tests/integration/cases/types/intersection-props/__expected__/diagnostics.json b/tests/integration/cases/types/intersection-props/__expected__/diagnostics.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__expected__/diagnostics.json @@ -0,0 +1 @@ +[] diff --git a/tests/integration/cases/types/intersection-props/__expected__/dom.accent.html b/tests/integration/cases/types/intersection-props/__expected__/dom.accent.html new file mode 100644 index 000000000..5480c83ad --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__expected__/dom.accent.html @@ -0,0 +1,3 @@ + + "Live" + diff --git a/tests/integration/cases/types/intersection-props/__expected__/dom.small.html b/tests/integration/cases/types/intersection-props/__expected__/dom.small.html new file mode 100644 index 000000000..d0e3140f1 --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__expected__/dom.small.html @@ -0,0 +1,3 @@ + + "Draft" + diff --git a/tests/integration/cases/types/intersection-props/__expected__/geometry.accent.json b/tests/integration/cases/types/intersection-props/__expected__/geometry.accent.json new file mode 100644 index 000000000..0ebbb087a --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__expected__/geometry.accent.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "nodes": { + "span[0]": { + "box": [ + 8, + 10, + 30.9375, + 20 + ], + "style": { + "display": "inline", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "span[0]/#text[0]": { + "box": [ + [ + 8, + 10, + 30.9375, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/types/intersection-props/__expected__/geometry.small.json b/tests/integration/cases/types/intersection-props/__expected__/geometry.small.json new file mode 100644 index 000000000..21b7178de --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__expected__/geometry.small.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "nodes": { + "span[0]": { + "box": [ + 8, + 10, + 37.171875, + 20 + ], + "style": { + "display": "inline", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "0px", + "margin-right": "0px", + "margin-bottom": "0px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "span[0]/#text[0]": { + "box": [ + [ + 8, + 10, + 37.171875, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/types/intersection-props/__expected__/ssr.accent.html b/tests/integration/cases/types/intersection-props/__expected__/ssr.accent.html new file mode 100644 index 000000000..5480c83ad --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__expected__/ssr.accent.html @@ -0,0 +1,3 @@ + + "Live" + diff --git a/tests/integration/cases/types/intersection-props/__expected__/ssr.small.html b/tests/integration/cases/types/intersection-props/__expected__/ssr.small.html new file mode 100644 index 000000000..d0e3140f1 --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__expected__/ssr.small.html @@ -0,0 +1,3 @@ + + "Draft" + diff --git a/tests/integration/cases/types/intersection-props/__output__/angular/chip.ts b/tests/integration/cases/types/intersection-props/__output__/angular/chip.ts new file mode 100644 index 000000000..16fe80cd1 --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/angular/chip.ts @@ -0,0 +1,61 @@ +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + inject, + input, +} from "@angular/core"; + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-chip", + imports: [ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let size = this.size(); + @let tone = this.tone(); + @let label = this.label(); + {{ label }} + `, +}) +export default class Chip { + readonly size = input.required<"small" | "large">(); + readonly tone = input<"neutral" | "accent", "neutral" | "accent" | undefined>("neutral", { + transform: (value) => (value === undefined ? "neutral" : value), + }); + readonly label = input.required(); + readonly class = input(); + readonly style = input>(); + protected readonly host = inject>(ElementRef).nativeElement; +} diff --git a/tests/integration/cases/types/intersection-props/__output__/angular/types.ts b/tests/integration/cases/types/intersection-props/__output__/angular/types.ts new file mode 100644 index 000000000..2070c397e --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/angular/types.ts @@ -0,0 +1,7 @@ +export interface Sized { + size: "small" | "large"; +} + +export interface Toned { + tone?: "neutral" | "accent"; +} diff --git a/tests/integration/cases/types/intersection-props/__output__/astro/Chip.astro b/tests/integration/cases/types/intersection-props/__output__/astro/Chip.astro new file mode 100644 index 000000000..7353470a2 --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/astro/Chip.astro @@ -0,0 +1,23 @@ +--- +type ChipProps = Sized & Toned & { label: string }; + +interface Props extends ChipProps { + class?: string; + style?: Record; +} + +const { size, tone = "neutral", label, class: className, style } = Astro.props; + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} +--- + +{label} diff --git a/tests/integration/cases/types/intersection-props/__output__/astro/types.ts b/tests/integration/cases/types/intersection-props/__output__/astro/types.ts new file mode 100644 index 000000000..2070c397e --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/astro/types.ts @@ -0,0 +1,7 @@ +export interface Sized { + size: "small" | "large"; +} + +export interface Toned { + tone?: "neutral" | "accent"; +} diff --git a/tests/integration/cases/types/intersection-props/__output__/ir.json b/tests/integration/cases/types/intersection-props/__output__/ir.json new file mode 100644 index 000000000..fc48d991b --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/ir.json @@ -0,0 +1,260 @@ +{ + "irVersion": 1, + "file": "types/intersection-props/Chip.uf.tsx", + "components": [ + { + "name": "Chip", + "span": { + "start": 99, + "end": 249 + }, + "props": [ + { + "name": "size", + "optional": false, + "type": { + "code": "\"small\" | \"large\"", + "span": { + "start": 163, + "end": 172 + } + }, + "binding": "size@130", + "declaredBy": { + "file": "types/intersection-props/types.ts", + "declaration": "Sized", + "span": { + "start": 27, + "end": 51 + } + }, + "span": { + "start": 163, + "end": 172 + } + }, + { + "name": "tone", + "optional": true, + "type": { + "code": "\"neutral\" | \"accent\"", + "span": { + "start": 163, + "end": 172 + } + }, + "default": { + "code": "\"neutral\"", + "span": { + "start": 143, + "end": 152 + }, + "refs": [] + }, + "binding": "tone@136", + "declaredBy": { + "file": "types/intersection-props/types.ts", + "declaration": "Toned", + "span": { + "start": 82, + "end": 110 + } + }, + "span": { + "start": 163, + "end": 172 + } + }, + { + "name": "label", + "optional": false, + "type": { + "code": "string", + "span": { + "start": 88, + "end": 94 + } + }, + "binding": "label@154", + "span": { + "start": 81, + "end": 94 + } + } + ], + "propsParameter": { + "form": "destructured", + "type": { + "code": "ChipProps", + "span": { + "start": 163, + "end": 172 + } + }, + "span": { + "start": 128, + "end": 172 + } + }, + "types": [ + "ChipProps" + ], + "bindings": [ + { + "id": "size@130", + "name": "size", + "kind": "prop", + "span": { + "start": 130, + "end": 134 + } + }, + { + "id": "tone@136", + "name": "tone", + "kind": "prop", + "span": { + "start": 136, + "end": 140 + } + }, + { + "id": "label@154", + "name": "label", + "kind": "prop", + "span": { + "start": 154, + "end": 159 + } + } + ], + "setup": [], + "render": { + "kind": "Element", + "tag": "span", + "attributes": [ + { + "kind": "Class", + "items": [ + { + "kind": "Dynamic", + "value": { + "code": "`chip chip-${size} chip-${tone}`", + "span": { + "start": 198, + "end": 230 + }, + "refs": [ + { + "kind": "Binding", + "binding": "size@130", + "span": { + "start": 211, + "end": 215 + } + }, + { + "kind": "Binding", + "binding": "tone@136", + "span": { + "start": 224, + "end": 228 + } + } + ] + }, + "span": { + "start": 198, + "end": 230 + } + } + ], + "span": { + "start": 191, + "end": 231 + } + } + ], + "children": [ + { + "kind": "Interpolation", + "value": { + "code": "label", + "span": { + "start": 233, + "end": 238 + }, + "refs": [ + { + "kind": "Binding", + "binding": "label@154", + "span": { + "start": 233, + "end": 238 + } + } + ] + }, + "span": { + "start": 232, + "end": 239 + } + } + ], + "span": { + "start": 185, + "end": 246 + } + } + } + ], + "exports": [ + { + "kind": "default", + "name": "default", + "local": "Chip", + "span": { + "start": 99, + "end": 249 + } + } + ], + "types": [ + { + "name": "ChipProps", + "exported": false, + "code": "type ChipProps = Sized & Toned & { label: string };", + "span": { + "start": 46, + "end": 97 + } + } + ], + "typeImports": [ + { + "specifier": "./types", + "names": [ + { + "imported": "Sized", + "local": "Sized", + "span": { + "start": 14, + "end": 19 + } + }, + { + "imported": "Toned", + "local": "Toned", + "span": { + "start": 21, + "end": 26 + } + } + ], + "span": { + "start": 0, + "end": 44 + } + } + ] +} diff --git a/tests/integration/cases/types/intersection-props/__output__/qwik/Chip.tsx b/tests/integration/cases/types/intersection-props/__output__/qwik/Chip.tsx new file mode 100644 index 000000000..97299be76 --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/qwik/Chip.tsx @@ -0,0 +1,26 @@ +import { type CSSProperties, type ClassList, component$ } from "@qwik.dev/core"; + +type ChipProps = Sized & Toned & { label: string }; + +export default component$( + ({ size, tone = "neutral", label, class: className, style, ...attributes }) => { + return ( + + {label} + + ); + }, +); + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/intersection-props/__output__/qwik/types.ts b/tests/integration/cases/types/intersection-props/__output__/qwik/types.ts new file mode 100644 index 000000000..2070c397e --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/qwik/types.ts @@ -0,0 +1,7 @@ +export interface Sized { + size: "small" | "large"; +} + +export interface Toned { + tone?: "neutral" | "accent"; +} diff --git a/tests/integration/cases/types/intersection-props/__output__/react/Chip.tsx b/tests/integration/cases/types/intersection-props/__output__/react/Chip.tsx new file mode 100644 index 000000000..00142aa1d --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/react/Chip.tsx @@ -0,0 +1,51 @@ +import type { CSSProperties } from "react"; + +type ChipProps = Sized & Toned & { label: string }; + +export interface ChipAttributes { + className?: string; + style?: CSSProperties; +} + +export default function Chip({ + size, + tone = "neutral", + label, + className, + style, + ...attributes +}: ChipProps & ChipAttributes) { + return ( + + {label} + + ); +} + +/** Joins class names, and the keys of an object's truthy entries, into one `className`. */ +function cx(...parts: unknown[]): string { + const names: string[] = []; + for (const part of parts) { + if (typeof part === "string") { + if (part) names.push(part); + } else if (part && typeof part === "object") { + for (const [key, on] of Object.entries(part)) { + if (on) names.push(key); + } + } + } + return names.join(" "); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("data-uf-")) scope[key] = ""; + } + return scope; +} diff --git a/tests/integration/cases/types/intersection-props/__output__/react/types.ts b/tests/integration/cases/types/intersection-props/__output__/react/types.ts new file mode 100644 index 000000000..2070c397e --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/react/types.ts @@ -0,0 +1,7 @@ +export interface Sized { + size: "small" | "large"; +} + +export interface Toned { + tone?: "neutral" | "accent"; +} diff --git a/tests/integration/cases/types/intersection-props/__output__/solid/Chip.tsx b/tests/integration/cases/types/intersection-props/__output__/solid/Chip.tsx new file mode 100644 index 000000000..f0ee46a45 --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/solid/Chip.tsx @@ -0,0 +1,35 @@ +import { type JSX, mergeProps } from "solid-js"; + +type ChipProps = Sized & Toned & { label: string }; + +export default function Chip(rawProps: ChipProps & { class?: string; style?: JSX.CSSProperties }) { + const props = mergeProps({ tone: "neutral" } satisfies Partial, rawProps); + return ( + + {props.label} + + ); +} + +/** Joins class names: strings as they are, and the names of an object's truthy entries. */ +function cx(...parts: unknown[]): string { + const names: string[] = []; + for (const part of parts) { + if (typeof part === "string") names.push(part); + else if (part && typeof part === "object") { + for (const [name, on] of Object.entries(part)) if (on) names.push(name); + } + } + return names.join(" "); +} + +/** The scope attributes of the styled parents that render this component, for its root. */ +function scopeAttributes(props: object): Record { + const scope: Record = {}; + for (const name of Object.keys(props)) if (name.startsWith("data-uf-")) scope[name] = ""; + return scope; +} diff --git a/tests/integration/cases/types/intersection-props/__output__/solid/types.ts b/tests/integration/cases/types/intersection-props/__output__/solid/types.ts new file mode 100644 index 000000000..2070c397e --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/solid/types.ts @@ -0,0 +1,7 @@ +export interface Sized { + size: "small" | "large"; +} + +export interface Toned { + tone?: "neutral" | "accent"; +} diff --git a/tests/integration/cases/types/intersection-props/__output__/svelte/Chip.svelte b/tests/integration/cases/types/intersection-props/__output__/svelte/Chip.svelte new file mode 100644 index 000000000..3d1dc1a36 --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/svelte/Chip.svelte @@ -0,0 +1,24 @@ + + + + +{label} diff --git a/tests/integration/cases/types/intersection-props/__output__/svelte/types.ts b/tests/integration/cases/types/intersection-props/__output__/svelte/types.ts new file mode 100644 index 000000000..2070c397e --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/svelte/types.ts @@ -0,0 +1,7 @@ +export interface Sized { + size: "small" | "large"; +} + +export interface Toned { + tone?: "neutral" | "accent"; +} diff --git a/tests/integration/cases/types/intersection-props/__output__/vue/Chip.vue b/tests/integration/cases/types/intersection-props/__output__/vue/Chip.vue new file mode 100644 index 000000000..952aa0c3d --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/vue/Chip.vue @@ -0,0 +1,11 @@ + + + diff --git a/tests/integration/cases/types/intersection-props/__output__/vue/types.ts b/tests/integration/cases/types/intersection-props/__output__/vue/types.ts new file mode 100644 index 000000000..2070c397e --- /dev/null +++ b/tests/integration/cases/types/intersection-props/__output__/vue/types.ts @@ -0,0 +1,7 @@ +export interface Sized { + size: "small" | "large"; +} + +export interface Toned { + tone?: "neutral" | "accent"; +} diff --git a/tests/integration/cases/types/intersection-props/__screenshots__/accent-chromium-linux.png b/tests/integration/cases/types/intersection-props/__screenshots__/accent-chromium-linux.png new file mode 100644 index 000000000..cf8664f05 Binary files /dev/null and b/tests/integration/cases/types/intersection-props/__screenshots__/accent-chromium-linux.png differ diff --git a/tests/integration/cases/types/intersection-props/__screenshots__/small-chromium-linux.png b/tests/integration/cases/types/intersection-props/__screenshots__/small-chromium-linux.png new file mode 100644 index 000000000..1ce03902b Binary files /dev/null and b/tests/integration/cases/types/intersection-props/__screenshots__/small-chromium-linux.png differ diff --git a/tests/integration/cases/types/intersection-props/case.json b/tests/integration/cases/types/intersection-props/case.json new file mode 100644 index 000000000..e5f468bc4 --- /dev/null +++ b/tests/integration/cases/types/intersection-props/case.json @@ -0,0 +1,7 @@ +{ + "description": "Props typed with an intersection of two imported interfaces and a type literal.", + "ssr": { + "small": { "props": { "size": "small", "label": "Draft" } }, + "accent": { "props": { "size": "large", "tone": "accent", "label": "Live" } } + } +} diff --git a/tests/integration/cases/types/intersection-props/intersection-props.test.ts b/tests/integration/cases/types/intersection-props/intersection-props.test.ts new file mode 100644 index 000000000..027752f3e --- /dev/null +++ b/tests/integration/cases/types/intersection-props/intersection-props.test.ts @@ -0,0 +1,20 @@ +// types/intersection-props: props typed with an intersection of imported interfaces and a type +// literal (ADR-0067): every part's members are props. +import { describeTargets, it, mountScenario } from "@unframework/testing"; +import { expect } from "vitest"; + +import Chip from "./Chip.uf.tsx"; + +describeTargets("types/intersection-props", () => { + it("renders every part's members", async () => { + const view = await mountScenario(Chip, "small"); + await view.expectParity("small"); + await expect.element(view.getByText("Draft")).toHaveClass("chip-small chip-neutral"); + }); + + it("renders the optional member it is given", async () => { + const view = await mountScenario(Chip, "accent"); + await view.expectParity("accent"); + await expect.element(view.getByText("Live")).toHaveClass("chip-large chip-accent"); + }); +}); diff --git a/tests/integration/cases/types/intersection-props/types.ts b/tests/integration/cases/types/intersection-props/types.ts new file mode 100644 index 000000000..2070c397e --- /dev/null +++ b/tests/integration/cases/types/intersection-props/types.ts @@ -0,0 +1,7 @@ +export interface Sized { + size: "small" | "large"; +} + +export interface Toned { + tone?: "neutral" | "accent"; +} diff --git a/tests/integration/cases/types/package-types/Status.uf.tsx b/tests/integration/cases/types/package-types/Status.uf.tsx new file mode 100644 index 000000000..8d19aad9a --- /dev/null +++ b/tests/integration/cases/types/package-types/Status.uf.tsx @@ -0,0 +1,15 @@ +import type { TimeInterval } from "rxjs"; + +import type { Labelled } from "./types"; + +// Members a package declares, `value` and `interval` from rxjs's generic `TimeInterval`, beside +// one a `.ts` module declares. +type StatusProps = TimeInterval & Labelled; + +export default function Status({ value, interval, label }: StatusProps) { + return ( +

+ {label}: {value} after {interval} ms +

+ ); +} diff --git a/tests/integration/cases/types/package-types/__expected__/aria.tick.yaml b/tests/integration/cases/types/package-types/__expected__/aria.tick.yaml new file mode 100644 index 000000000..6126db8e9 --- /dev/null +++ b/tests/integration/cases/types/package-types/__expected__/aria.tick.yaml @@ -0,0 +1 @@ +- paragraph: "Feed: tick after 250 ms" diff --git a/tests/integration/cases/types/package-types/__expected__/aria.tock.yaml b/tests/integration/cases/types/package-types/__expected__/aria.tock.yaml new file mode 100644 index 000000000..41ba01abb --- /dev/null +++ b/tests/integration/cases/types/package-types/__expected__/aria.tock.yaml @@ -0,0 +1 @@ +- paragraph: "Clock: tock after 1000 ms" diff --git a/tests/integration/cases/types/package-types/__expected__/diagnostics.json b/tests/integration/cases/types/package-types/__expected__/diagnostics.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/tests/integration/cases/types/package-types/__expected__/diagnostics.json @@ -0,0 +1 @@ +[] diff --git a/tests/integration/cases/types/package-types/__expected__/dom.tick.html b/tests/integration/cases/types/package-types/__expected__/dom.tick.html new file mode 100644 index 000000000..89f95045e --- /dev/null +++ b/tests/integration/cases/types/package-types/__expected__/dom.tick.html @@ -0,0 +1,3 @@ +

+ "Feed: tick after 250 ms" +

diff --git a/tests/integration/cases/types/package-types/__expected__/dom.tock.html b/tests/integration/cases/types/package-types/__expected__/dom.tock.html new file mode 100644 index 000000000..c3630b16b --- /dev/null +++ b/tests/integration/cases/types/package-types/__expected__/dom.tock.html @@ -0,0 +1,3 @@ +

+ "Clock: tock after 1000 ms" +

diff --git a/tests/integration/cases/types/package-types/__expected__/geometry.tick.json b/tests/integration/cases/types/package-types/__expected__/geometry.tick.json new file mode 100644 index 000000000..b2297f6c7 --- /dev/null +++ b/tests/integration/cases/types/package-types/__expected__/geometry.tick.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "nodes": { + "p[0]": { + "box": [ + 8, + 24, + 173.5, + 24 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "16px", + "margin-right": "0px", + "margin-bottom": "16px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "p[0]/#text[0]": { + "box": [ + [ + 8, + 26, + 173.5, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/types/package-types/__expected__/geometry.tock.json b/tests/integration/cases/types/package-types/__expected__/geometry.tock.json new file mode 100644 index 000000000..bf97bb4d4 --- /dev/null +++ b/tests/integration/cases/types/package-types/__expected__/geometry.tock.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "nodes": { + "p[0]": { + "box": [ + 8, + 24, + 192.328125, + 24 + ], + "style": { + "display": "block", + "position": "static", + "top": "auto", + "right": "auto", + "bottom": "auto", + "left": "auto", + "float": "none", + "box-sizing": "content-box", + "margin-top": "16px", + "margin-right": "0px", + "margin-bottom": "16px", + "margin-left": "0px", + "padding-top": "0px", + "padding-right": "0px", + "padding-bottom": "0px", + "padding-left": "0px", + "border-top-width": "0px", + "border-right-width": "0px", + "border-bottom-width": "0px", + "border-left-width": "0px", + "border-top-style": "none", + "border-right-style": "none", + "border-bottom-style": "none", + "border-left-style": "none", + "border-top-color": "rgb(0, 0, 0)", + "border-right-color": "rgb(0, 0, 0)", + "border-bottom-color": "rgb(0, 0, 0)", + "border-left-color": "rgb(0, 0, 0)", + "border-top-left-radius": "0px", + "border-top-right-radius": "0px", + "border-bottom-right-radius": "0px", + "border-bottom-left-radius": "0px", + "color": "rgb(0, 0, 0)", + "background-color": "rgba(0, 0, 0, 0)", + "background-image": "none", + "opacity": "1", + "visibility": "visible", + "font-family": "\"UF Test Sans\"", + "font-size": "16px", + "font-weight": "400", + "font-style": "normal", + "line-height": "24px", + "letter-spacing": "normal", + "word-spacing": "0px", + "text-align": "start", + "text-decoration-line": "none", + "text-transform": "none", + "text-shadow": "none", + "white-space": "normal", + "vertical-align": "baseline", + "overflow-x": "visible", + "overflow-y": "visible", + "z-index": "auto", + "transform": "none", + "box-shadow": "none", + "outline-style": "none", + "outline-width": "3px", + "outline-color": "rgb(0, 0, 0)", + "outline-offset": "0px", + "list-style-type": "disc", + "flex-direction": "row", + "flex-wrap": "nowrap", + "justify-content": "normal", + "align-items": "normal", + "row-gap": "normal", + "column-gap": "normal", + "grid-template-columns": "none", + "grid-template-rows": "none" + } + }, + "p[0]/#text[0]": { + "box": [ + [ + 8, + 26, + 192.328125, + 20 + ] + ] + } + } +} diff --git a/tests/integration/cases/types/package-types/__expected__/ssr.tick.html b/tests/integration/cases/types/package-types/__expected__/ssr.tick.html new file mode 100644 index 000000000..89f95045e --- /dev/null +++ b/tests/integration/cases/types/package-types/__expected__/ssr.tick.html @@ -0,0 +1,3 @@ +

+ "Feed: tick after 250 ms" +

diff --git a/tests/integration/cases/types/package-types/__expected__/ssr.tock.html b/tests/integration/cases/types/package-types/__expected__/ssr.tock.html new file mode 100644 index 000000000..c3630b16b --- /dev/null +++ b/tests/integration/cases/types/package-types/__expected__/ssr.tock.html @@ -0,0 +1,3 @@ +

+ "Clock: tock after 1000 ms" +

diff --git a/tests/integration/cases/types/package-types/__output__/angular/status.ts b/tests/integration/cases/types/package-types/__output__/angular/status.ts new file mode 100644 index 000000000..13ae5b8fe --- /dev/null +++ b/tests/integration/cases/types/package-types/__output__/angular/status.ts @@ -0,0 +1,59 @@ +import { + Component, + Directive, + ElementRef, + type OnInit, + Renderer2, + inject, + input, +} from "@angular/core"; + +/** The scope attributes taken off each host, for the roots its component renders later. */ +const scopes = new WeakMap(); + +/** + * Gives a root node of its component the scope attributes the component's styled parents put on + * its host (ADR-0063), and takes them off the host, which keeps only `display: contents`. + */ +@Directive({ selector: "[ufScope]" }) +export class ScopeAttributes implements OnInit { + readonly ufScope = input.required(); + private readonly element = inject>(ElementRef); + private readonly renderer = inject(Renderer2); + + ngOnInit(): void { + const host = this.ufScope(); + let names = scopes.get(host); + if (!names) { + names = host.getAttributeNames().filter((name) => name.startsWith("data-uf-")); + for (const name of names) this.renderer.removeAttribute(host, name); + scopes.set(host, names); + } + for (const name of names) this.renderer.setAttribute(this.element.nativeElement, name, ""); + } +} + +@Component({ + selector: "uf-status", + imports: [ScopeAttributes], + host: { "[attr.class]": "null", "[attr.style]": "'display: contents;'" }, + preserveWhitespaces: false, + template: ` + @let value = this.value(); + @let interval = this.interval(); + @let label = this.label(); +

{{ label }}: {{ value }} after {{ interval }} ms

+ `, +}) +export default class Status { + readonly value = input.required(); + readonly interval = input.required(); + readonly label = input.required