affora 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/CHECKS.md +40 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/agent.md +70 -0
- package/checks/cli.mjs +44 -0
- package/checks/substrate.mjs +269 -0
- package/cli/affora.mjs +131 -0
- package/design.md +113 -0
- package/package.json +71 -0
- package/registry.json +83 -0
- package/rules/antd.js +173 -0
- package/rules/generic.js +299 -0
- package/rules/hidden-radio.js +94 -0
- package/rules/magento.js +325 -0
- package/rules/mui.js +230 -0
- package/rules/shadcn-baseui.js +169 -0
- package/rules/shadcn-radix.js +289 -0
- package/src/components/combobox.tsx +279 -0
- package/src/components/datatable.tsx +454 -0
- package/src/components/dialog.tsx +548 -0
- package/src/components/productcard.tsx +551 -0
- package/src/components/settingsform.tsx +583 -0
- package/src/patterns/confirmundo.tsx +134 -0
- package/src/patterns/errremedy.tsx +155 -0
- package/src/patterns/flowform.tsx +179 -0
- package/src/patterns/gatedaction.tsx +157 -0
- package/src/patterns/persistentfeedback.tsx +106 -0
- package/src/primitives/actionbutton.tsx +64 -0
- package/src/primitives/textfield.tsx +57 -0
- package/src/tokens/themes.css +1068 -0
package/cli/affora.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { access, copyFile, mkdir, readFile } from "node:fs/promises"
|
|
4
|
+
import { constants } from "node:fs"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import process from "node:process"
|
|
7
|
+
import { fileURLToPath } from "node:url"
|
|
8
|
+
|
|
9
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
10
|
+
const manifest = JSON.parse(await readFile(path.join(packageRoot, "registry.json"), "utf8"))
|
|
11
|
+
const items = manifest.items
|
|
12
|
+
|
|
13
|
+
function usage() {
|
|
14
|
+
return `Affora copy-in component CLI
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
affora list
|
|
18
|
+
affora add <item...> [--cwd <directory>] [--path <directory>] [--force] [--dry-run]
|
|
19
|
+
affora add --all [--cwd <directory>] [--path <directory>] [--force] [--dry-run]
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
npx affora add combobox
|
|
23
|
+
npx affora add product-card dialog --path src/ui
|
|
24
|
+
npx affora add --all
|
|
25
|
+
npx affora add themes --path src/styles
|
|
26
|
+
|
|
27
|
+
Components automatically include the Affora theme tokens. Existing files are
|
|
28
|
+
never overwritten unless --force is passed.`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fail(message) {
|
|
32
|
+
console.error(`affora: ${message}`)
|
|
33
|
+
process.exitCode = 1
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parse(argv) {
|
|
37
|
+
const [command, ...rest] = argv
|
|
38
|
+
const options = { cwd: process.cwd(), path: "src", force: false, dryRun: false, all: false }
|
|
39
|
+
const names = []
|
|
40
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
41
|
+
const arg = rest[i]
|
|
42
|
+
if (arg === "--force") options.force = true
|
|
43
|
+
else if (arg === "--dry-run") options.dryRun = true
|
|
44
|
+
else if (arg === "--all") options.all = true
|
|
45
|
+
else if (arg === "--cwd" || arg === "--path") {
|
|
46
|
+
const value = rest[++i]
|
|
47
|
+
if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`)
|
|
48
|
+
options[arg.slice(2)] = value
|
|
49
|
+
} else if (arg.startsWith("--")) throw new Error(`unknown option ${arg}`)
|
|
50
|
+
else names.push(arg)
|
|
51
|
+
}
|
|
52
|
+
return { command, names, options }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveNames(requested) {
|
|
56
|
+
const resolved = []
|
|
57
|
+
const visiting = new Set()
|
|
58
|
+
const add = (name) => {
|
|
59
|
+
if (!items[name]) throw new Error(`unknown item "${name}"; run \`affora list\``)
|
|
60
|
+
if (visiting.has(name)) return
|
|
61
|
+
visiting.add(name)
|
|
62
|
+
for (const dependency of items[name].requires) add(dependency)
|
|
63
|
+
resolved.push(name)
|
|
64
|
+
}
|
|
65
|
+
for (const name of requested) add(name)
|
|
66
|
+
return resolved
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function exists(filename) {
|
|
70
|
+
try {
|
|
71
|
+
await access(filename, constants.F_OK)
|
|
72
|
+
return true
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function add(requested, options) {
|
|
79
|
+
if (requested.length === 0) throw new Error("add requires at least one item")
|
|
80
|
+
const names = resolveNames(requested)
|
|
81
|
+
const base = path.resolve(options.cwd, options.path)
|
|
82
|
+
const planned = names.map((name) => ({
|
|
83
|
+
name,
|
|
84
|
+
source: path.join(packageRoot, items[name].source),
|
|
85
|
+
destination: path.join(base, items[name].target),
|
|
86
|
+
}))
|
|
87
|
+
|
|
88
|
+
if (!options.force) {
|
|
89
|
+
const conflicts = []
|
|
90
|
+
for (const file of planned) if (await exists(file.destination)) conflicts.push(file.destination)
|
|
91
|
+
if (conflicts.length > 0) {
|
|
92
|
+
throw new Error(`refusing to overwrite ${conflicts.map((file) => path.relative(options.cwd, file)).join(", ")}; pass --force to replace`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const file of planned) {
|
|
97
|
+
const shown = path.relative(options.cwd, file.destination)
|
|
98
|
+
if (options.dryRun) console.log(`would add ${file.name} -> ${shown}`)
|
|
99
|
+
else {
|
|
100
|
+
await mkdir(path.dirname(file.destination), { recursive: true })
|
|
101
|
+
await copyFile(file.source, file.destination)
|
|
102
|
+
console.log(`added ${file.name} -> ${shown}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function main() {
|
|
108
|
+
let parsed
|
|
109
|
+
try {
|
|
110
|
+
parsed = parse(process.argv.slice(2))
|
|
111
|
+
if (!parsed.command || parsed.command === "help" || parsed.command === "--help" || parsed.command === "-h") {
|
|
112
|
+
console.log(usage())
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (parsed.command === "list") {
|
|
116
|
+
for (const [name, item] of Object.entries(items)) console.log(`${name.padEnd(14)} ${item.description}`)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (parsed.command === "add") {
|
|
120
|
+
if (parsed.options.all && parsed.names.length) throw new Error("use item names or --all, not both")
|
|
121
|
+
const requested = parsed.options.all ? Object.keys(items).filter((name) => name !== "themes") : parsed.names
|
|
122
|
+
await add(requested, parsed.options)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`unknown command "${parsed.command}"`)
|
|
126
|
+
} catch (error) {
|
|
127
|
+
fail(error instanceof Error ? error.message : String(error))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await main()
|
package/design.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Designing with Affora
|
|
2
|
+
|
|
3
|
+
Affora is a design system for interfaces with two readers: a person who sees
|
|
4
|
+
the page and an agent that enumerates the document. Its central contract is:
|
|
5
|
+
|
|
6
|
+
> What an interface declares is fixed and checked. What it paints is free.
|
|
7
|
+
|
|
8
|
+
Affora calls the declared document the **substrate** and its visual expression
|
|
9
|
+
the **skin**. A skin may change colour, typography, shape, spacing, layout,
|
|
10
|
+
motion, and effects. It must not remove or rename the controls, facts, state,
|
|
11
|
+
or relationships on which either reader depends.
|
|
12
|
+
|
|
13
|
+
## Three layers
|
|
14
|
+
|
|
15
|
+
Use one vocabulary throughout product and code:
|
|
16
|
+
|
|
17
|
+
1. **Primitive** — a single native interaction such as a button, input, or
|
|
18
|
+
select. Prefer the native element and give it a persistent accessible name.
|
|
19
|
+
2. **Composition** — several primitives that express one coherent object, such
|
|
20
|
+
as a product card, data table, or settings form.
|
|
21
|
+
3. **Flow pattern** — state and actions across time, such as a multi-step form,
|
|
22
|
+
gated action, recoverable deletion, or error-remedy sequence.
|
|
23
|
+
|
|
24
|
+
The current registry contains Affora's native primitive layer, five
|
|
25
|
+
compositions, and five flow patterns. Affora does not rebrand the project's
|
|
26
|
+
shadcn control condition as its primitive layer; each published item uses
|
|
27
|
+
native controls and has no experiment-harness dependency.
|
|
28
|
+
|
|
29
|
+
## The substrate rules
|
|
30
|
+
|
|
31
|
+
### State is stated
|
|
32
|
+
|
|
33
|
+
Render task-relevant state as document text or native state, not only as
|
|
34
|
+
colour, position, or styling. Say which item is selected, which page is current,
|
|
35
|
+
what was saved, and which actions have completed. A count alone is not a
|
|
36
|
+
receipt: name the completed items when identity matters.
|
|
37
|
+
|
|
38
|
+
### Answers are present
|
|
39
|
+
|
|
40
|
+
If content can answer the reader's question, keep it in the document. It may be
|
|
41
|
+
visually collapsed, clipped, or progressively revealed, but interaction must
|
|
42
|
+
not be the only way for a document reader to discover that it exists.
|
|
43
|
+
|
|
44
|
+
### Affordances are real and named
|
|
45
|
+
|
|
46
|
+
Use native controls. Give every control a stable name, preferably visible text.
|
|
47
|
+
An icon can accompany the name but must not be its only carrier. The visible
|
|
48
|
+
and operable target must occupy the same place.
|
|
49
|
+
|
|
50
|
+
### Meaning has more than one carrier
|
|
51
|
+
|
|
52
|
+
Colour, geometry, motion, and position may reinforce meaning; none may be the
|
|
53
|
+
only source of it. Status, severity, destructive consequence, and selection
|
|
54
|
+
must also be stated.
|
|
55
|
+
|
|
56
|
+
### Structure matches hierarchy
|
|
57
|
+
|
|
58
|
+
Use headings for headings, labels for fields, fieldsets for grouped choices,
|
|
59
|
+
tables for tabular relationships, and lists for lists. Visual rank may vary by
|
|
60
|
+
theme; semantic rank must remain truthful.
|
|
61
|
+
|
|
62
|
+
### Constraints precede failure
|
|
63
|
+
|
|
64
|
+
State required values, formats, ranges, and unlocking conditions before the
|
|
65
|
+
reader attempts an action. A disabled control must name why it is disabled and
|
|
66
|
+
the control or action that resolves the condition.
|
|
67
|
+
|
|
68
|
+
### Feedback persists
|
|
69
|
+
|
|
70
|
+
Keep outcomes readable after the action completes. Toasts may provide immediate
|
|
71
|
+
feedback, but durable state must also remain in the document. Error messages
|
|
72
|
+
must say what failed, why, and what named action corrects it.
|
|
73
|
+
|
|
74
|
+
### Vocabulary and shape stay stable
|
|
75
|
+
|
|
76
|
+
Use one name for one concept and one semantic shape for one pattern. This helps
|
|
77
|
+
people learn the product and lets agents reuse a successful action recipe.
|
|
78
|
+
|
|
79
|
+
## Flow rules
|
|
80
|
+
|
|
81
|
+
- Minimise interaction depth. Each required step adds an observation cycle and
|
|
82
|
+
another place to lose context.
|
|
83
|
+
- Make each screen self-describing. Restate task facts and progress needed to
|
|
84
|
+
continue; do not assume access to the previous screen.
|
|
85
|
+
- Offer a direct path beside guided presentation. Visual staging must not require
|
|
86
|
+
serial interaction when the underlying task can be submitted directly.
|
|
87
|
+
- Prefer cheap reversal to routine confirmation. Mark irreversible actions
|
|
88
|
+
explicitly; use undo where recovery is possible.
|
|
89
|
+
- State completion and leave a durable result the reader can verify.
|
|
90
|
+
|
|
91
|
+
## Skin contract
|
|
92
|
+
|
|
93
|
+
Components consume semantic custom properties from `themes.css`; they do not
|
|
94
|
+
choose a theme. Apply a style and layout at an ancestor:
|
|
95
|
+
|
|
96
|
+
```html
|
|
97
|
+
<html data-flagship="swiss" data-layout="saas" data-mode="light">
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Changing those attributes may change the skin, never the substrate. When adding
|
|
101
|
+
a theme, define the complete token vocabulary and preserve token meaning across
|
|
102
|
+
light and dark modes. Do not target a component's internal classes from a theme.
|
|
103
|
+
|
|
104
|
+
## Review gate
|
|
105
|
+
|
|
106
|
+
Before accepting a component or rewrite, verify all three statements:
|
|
107
|
+
|
|
108
|
+
1. The page still **says** every task-relevant fact and state.
|
|
109
|
+
2. The page still **has** every required affordance with a stable name.
|
|
110
|
+
3. Using an affordance still **causes** the same product effect.
|
|
111
|
+
|
|
112
|
+
Then run Affora's executable checks. A passing check suite establishes document
|
|
113
|
+
conformance; it does not guarantee that every agent will complete every task.
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "affora",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Components for interfaces with two readers: a person, and an agent that reads the page",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react",
|
|
7
|
+
"components",
|
|
8
|
+
"design-system",
|
|
9
|
+
"accessibility",
|
|
10
|
+
"agents",
|
|
11
|
+
"copy-in"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"homepage": "https://affora.design",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/srcJin/Agent_Friendly_Web_Design.git",
|
|
19
|
+
"directory": "library"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/srcJin/Agent_Friendly_Web_Design/issues"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"affora": "cli/affora.mjs"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"cli/affora.mjs",
|
|
35
|
+
"registry.json",
|
|
36
|
+
"src/primitives/",
|
|
37
|
+
"src/components/",
|
|
38
|
+
"src/patterns/",
|
|
39
|
+
"src/tokens/",
|
|
40
|
+
"checks/",
|
|
41
|
+
"rules/",
|
|
42
|
+
"README.md",
|
|
43
|
+
"design.md",
|
|
44
|
+
"agent.md",
|
|
45
|
+
"CHANGELOG.md",
|
|
46
|
+
"CHECKS.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"demo": "vite demo --port 5280 --strictPort",
|
|
51
|
+
"build:demo": "vite build demo --config demo/vite.config.js",
|
|
52
|
+
"check": "node checks/cli.mjs",
|
|
53
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
54
|
+
"test:cli": "node --test cli/affora.test.mjs",
|
|
55
|
+
"test": "npm run typecheck && npm run test:cli"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"react": ">=18",
|
|
59
|
+
"react-dom": ">=18"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/react": "^19.3.0",
|
|
63
|
+
"@types/react-dom": "^19.3.0",
|
|
64
|
+
"@vitejs/plugin-react": "^4.3.3",
|
|
65
|
+
"playwright": "^1.63.0",
|
|
66
|
+
"react": "^18.3.1",
|
|
67
|
+
"react-dom": "^18.3.1",
|
|
68
|
+
"typescript": "^7.0.2",
|
|
69
|
+
"vite": "^5.4.10"
|
|
70
|
+
}
|
|
71
|
+
}
|
package/registry.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"items": {
|
|
4
|
+
"action-button": {
|
|
5
|
+
"description": "Named native action with stated loading and destructive consequences.",
|
|
6
|
+
"source": "src/primitives/actionbutton.tsx",
|
|
7
|
+
"target": "primitives/affora/action-button.tsx",
|
|
8
|
+
"requires": ["themes"]
|
|
9
|
+
},
|
|
10
|
+
"text-field": {
|
|
11
|
+
"description": "Native text field with persistent label, constraint, and error remedy.",
|
|
12
|
+
"source": "src/primitives/textfield.tsx",
|
|
13
|
+
"target": "primitives/affora/text-field.tsx",
|
|
14
|
+
"requires": ["themes"]
|
|
15
|
+
},
|
|
16
|
+
"combobox": {
|
|
17
|
+
"description": "Searchable framework picker with enumerable native options.",
|
|
18
|
+
"source": "src/components/combobox.tsx",
|
|
19
|
+
"target": "components/affora/combobox.tsx",
|
|
20
|
+
"requires": ["themes"]
|
|
21
|
+
},
|
|
22
|
+
"data-table": {
|
|
23
|
+
"description": "Sortable semantic data table with named row actions.",
|
|
24
|
+
"source": "src/components/datatable.tsx",
|
|
25
|
+
"target": "components/affora/data-table.tsx",
|
|
26
|
+
"requires": ["themes"]
|
|
27
|
+
},
|
|
28
|
+
"dialog": {
|
|
29
|
+
"description": "Modal dialog with stated constraints and focus management.",
|
|
30
|
+
"source": "src/components/dialog.tsx",
|
|
31
|
+
"target": "components/affora/dialog.tsx",
|
|
32
|
+
"requires": ["themes"]
|
|
33
|
+
},
|
|
34
|
+
"product-card": {
|
|
35
|
+
"description": "Product card whose variant and quantity remain explicit state.",
|
|
36
|
+
"source": "src/components/productcard.tsx",
|
|
37
|
+
"target": "components/affora/product-card.tsx",
|
|
38
|
+
"requires": ["themes"]
|
|
39
|
+
},
|
|
40
|
+
"settings-form": {
|
|
41
|
+
"description": "Settings form with persistent labels, help, and saved state.",
|
|
42
|
+
"source": "src/components/settingsform.tsx",
|
|
43
|
+
"target": "components/affora/settings-form.tsx",
|
|
44
|
+
"requires": ["themes"]
|
|
45
|
+
},
|
|
46
|
+
"gated-action": {
|
|
47
|
+
"description": "Disabled action that persistently names its blocker and remedy.",
|
|
48
|
+
"source": "src/patterns/gatedaction.tsx",
|
|
49
|
+
"target": "patterns/affora/gated-action.tsx",
|
|
50
|
+
"requires": ["themes"]
|
|
51
|
+
},
|
|
52
|
+
"error-remedy": {
|
|
53
|
+
"description": "Persistent form error that names the conflict and corrective action.",
|
|
54
|
+
"source": "src/patterns/errremedy.tsx",
|
|
55
|
+
"target": "patterns/affora/error-remedy.tsx",
|
|
56
|
+
"requires": ["themes"]
|
|
57
|
+
},
|
|
58
|
+
"confirm-undo": {
|
|
59
|
+
"description": "Recoverable destructive action with a persistent named undo.",
|
|
60
|
+
"source": "src/patterns/confirmundo.tsx",
|
|
61
|
+
"target": "patterns/affora/confirm-undo.tsx",
|
|
62
|
+
"requires": ["themes"]
|
|
63
|
+
},
|
|
64
|
+
"flow-form": {
|
|
65
|
+
"description": "Guided form whose complete directly submittable substrate stays mounted.",
|
|
66
|
+
"source": "src/patterns/flowform.tsx",
|
|
67
|
+
"target": "patterns/affora/flow-form.tsx",
|
|
68
|
+
"requires": ["themes"]
|
|
69
|
+
},
|
|
70
|
+
"persistent-feedback": {
|
|
71
|
+
"description": "Action result and returned value that persist until superseded.",
|
|
72
|
+
"source": "src/patterns/persistentfeedback.tsx",
|
|
73
|
+
"target": "patterns/affora/persistent-feedback.tsx",
|
|
74
|
+
"requires": ["themes"]
|
|
75
|
+
},
|
|
76
|
+
"themes": {
|
|
77
|
+
"description": "Affora semantic and layout tokens with sixteen visual themes.",
|
|
78
|
+
"source": "src/tokens/themes.css",
|
|
79
|
+
"target": "styles/affora/themes.css",
|
|
80
|
+
"requires": []
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
package/rules/antd.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Ant Design rules — developed and verified against NATIVE antd projects
|
|
2
|
+
// (ant.design's own component demos), never against our benchmark re-creations,
|
|
3
|
+
// which would make the validation circular.
|
|
4
|
+
//
|
|
5
|
+
// Empirically probed idioms, and they differ from every other library here:
|
|
6
|
+
// Select the focusable element is an <input role="combobox"> whose VALUE is
|
|
7
|
+
// not in the input at all — it is rendered in a sibling
|
|
8
|
+
// .ant-select-selection-item, and the options portal into
|
|
9
|
+
// .ant-select-dropdown only once opened.
|
|
10
|
+
// Tabs the tab is a DIV (.ant-tabs-tab-btn[role=tab]), not a button, and
|
|
11
|
+
// inactive panels are unmounted like Radix's.
|
|
12
|
+
// Collapse .ant-collapse-header carries aria-expanded and the content lives in
|
|
13
|
+
// a sibling panel — .ant-collapse-panel in v6, .ant-collapse-content in
|
|
14
|
+
// v5 — which stays mounted once opened.
|
|
15
|
+
({
|
|
16
|
+
antd_select: {
|
|
17
|
+
find: () => [...document.querySelectorAll('input.ant-select-selection-search-input, input.ant-select-input, .ant-select input[role="combobox"]')],
|
|
18
|
+
extract: (el) => {
|
|
19
|
+
const wrap = el.closest('.ant-select');
|
|
20
|
+
if (!wrap) return null;
|
|
21
|
+
const owned = el.getAttribute('aria-controls');
|
|
22
|
+
let box = owned && document.getElementById(owned);
|
|
23
|
+
if (!box) box = document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
|
|
24
|
+
const opts = box ? [...box.querySelectorAll('.ant-select-item-option, [role="option"]')]
|
|
25
|
+
.map(o => ({ value: o.getAttribute('data-value') || rsRendered(o), label: rsRendered(o) }))
|
|
26
|
+
.filter(o => o.label) : [];
|
|
27
|
+
if (!opts.length) {
|
|
28
|
+
const tries = +(el.getAttribute('data-rs-probe') || 0);
|
|
29
|
+
if (tries >= 3) return null;
|
|
30
|
+
el.setAttribute('data-rs-probe', String(tries + 1));
|
|
31
|
+
// antd opens on mousedown on the SELECTOR, not on the inner input
|
|
32
|
+
const sel = wrap.querySelector('.ant-select-selector') || wrap;
|
|
33
|
+
sel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
34
|
+
sel.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
|
35
|
+
return 'retry';
|
|
36
|
+
}
|
|
37
|
+
// The chosen value is rendered beside the input, never inside it, so reading
|
|
38
|
+
// el.value returns empty on a select that plainly shows a selection. In
|
|
39
|
+
// multiple/tags mode there is one item PER selection, and reading only the
|
|
40
|
+
// first silently deleted every other chosen value — 17 lost facts on the
|
|
41
|
+
// demo page.
|
|
42
|
+
const chosen = [...wrap.querySelectorAll('.ant-select-selection-item')]
|
|
43
|
+
.map(n => rsRendered(n)).filter(Boolean);
|
|
44
|
+
const multi = wrap.classList.contains('ant-select-multiple') || chosen.length > 1;
|
|
45
|
+
const shown = chosen.join(', ');
|
|
46
|
+
const placeholder = rsRendered(wrap.querySelector('.ant-select-selection-placeholder'));
|
|
47
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
48
|
+
return { label: rsLabel(wrap), opts, current: shown, chosen, multi, placeholder,
|
|
49
|
+
wrap, id: el.getAttribute('id') || '' };
|
|
50
|
+
},
|
|
51
|
+
anchor: (el, d) => d.wrap,
|
|
52
|
+
render: (d) => {
|
|
53
|
+
const id = d.id || ('rs-' + (++window.__rsSeq));
|
|
54
|
+
const chosen = new Set(d.chosen || []);
|
|
55
|
+
// Options the page shows as chosen but that are not in the harvested list
|
|
56
|
+
// (a tags-mode select accepts free text) must still be stated, or the swap
|
|
57
|
+
// deletes what the user picked.
|
|
58
|
+
const extra = (d.chosen || []).filter(c => !d.opts.some(o => o.label === c));
|
|
59
|
+
return `<div class="rs rs-field">
|
|
60
|
+
${d.label ? `<label class="rs-label" for="${id}">${rsEsc(d.label)}</label>` : ''}
|
|
61
|
+
${d.multi && chosen.size ? `<p class="rs-label">Selected: ${rsEsc(d.current)}</p>` : ''}
|
|
62
|
+
<select class="rs-select" id="${id}"${d.multi ? ' multiple' : ''}>
|
|
63
|
+
${chosen.size ? '' : `<option value="" selected>${rsEsc(d.placeholder || 'Select…')}</option>`}
|
|
64
|
+
${extra.map(c => `<option value="${rsEsc(c)}" selected>${rsEsc(c)}</option>`).join('')}
|
|
65
|
+
${d.opts.map(o => `<option value="${rsEsc(o.value)}"${chosen.has(o.label) ? ' selected' : ''}>${rsEsc(o.label)}</option>`).join('')}
|
|
66
|
+
</select>
|
|
67
|
+
</div>`;
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
antd_tabs: {
|
|
72
|
+
// flattening: the tab/section controls exist to reveal content this
|
|
73
|
+
// output reveals structurally, so they are subsumed, not lost
|
|
74
|
+
subsumes: true,
|
|
75
|
+
find: () => {
|
|
76
|
+
// A NAVIGATION switcher is not a content tab strip. Atomic CRM renders its
|
|
77
|
+
// top navigation as MUI Tabs whose tabs are links; probing one navigated
|
|
78
|
+
// the application to its root and the flattening then replaced the page,
|
|
79
|
+
// costing the rewritten arm 25 of 36 tasks it could otherwise do. The
|
|
80
|
+
// generic tabs rule has carried this guard since Magento's mobile nav.
|
|
81
|
+
const contentTabs = (el) => !el.closest('nav,[role="navigation"]')
|
|
82
|
+
&& ![...el.querySelectorAll('[role="tab"]')].some(t => t.tagName === 'A' && t.getAttribute('href'));
|
|
83
|
+
return [...document.querySelectorAll('.ant-tabs-nav-list, [role="tablist"]')]
|
|
84
|
+
.filter(t => t.querySelector('[role="tab"]'))
|
|
85
|
+
.filter(contentTabs)
|
|
86
|
+
},
|
|
87
|
+
extract: (el) => {
|
|
88
|
+
const trigs = [...el.querySelectorAll('[role="tab"]')];
|
|
89
|
+
if (trigs.length < 2) return null;
|
|
90
|
+
const st = el.__rsCap || (el.__rsCap = { caps: {} });
|
|
91
|
+
let missing = null;
|
|
92
|
+
trigs.forEach(t => {
|
|
93
|
+
const key = rsRendered(t);
|
|
94
|
+
if (!key) return;
|
|
95
|
+
const id = t.getAttribute('aria-controls');
|
|
96
|
+
const panel = id ? document.getElementById(id) : null;
|
|
97
|
+
if (panel && rsRendered(panel)) st.caps[key] = { title: key, html: panel.innerHTML, panel };
|
|
98
|
+
else if (!(key in st.caps) && !missing) missing = t;
|
|
99
|
+
});
|
|
100
|
+
if (missing) {
|
|
101
|
+
const tries = +(el.getAttribute('data-rs-probe') || 0);
|
|
102
|
+
if (tries < trigs.length * 2 + 2) {
|
|
103
|
+
el.setAttribute('data-rs-probe', String(tries + 1));
|
|
104
|
+
// a DIV tab responds to click, not to form activation
|
|
105
|
+
missing.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
106
|
+
missing.click();
|
|
107
|
+
return 'retry';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Keep EVERY tab's title, not only the ones whose panel we captured. A demo
|
|
111
|
+
// with an overflow menu never mounts the hidden tabs' panels, so filtering
|
|
112
|
+
// to captured ones deleted the names 'Tab-28' and 'Tab-29' from a page that
|
|
113
|
+
// plainly lists them. A title with no body asserts that the tab exists and
|
|
114
|
+
// says nothing about its contents, which is exactly what we know.
|
|
115
|
+
const tabs = trigs.map(t => {
|
|
116
|
+
const key = rsRendered(t);
|
|
117
|
+
return key ? (st.caps[key] || { title: key, html: '', panel: null }) : null;
|
|
118
|
+
}).filter(Boolean);
|
|
119
|
+
return tabs.filter(t => t.html).length >= 1 && tabs.length >= 2 ? { tabs } : null;
|
|
120
|
+
},
|
|
121
|
+
render: (d) => `<div class="rs rs-sections">` + d.tabs.map(t =>
|
|
122
|
+
`<section class="rs-section"><h3 class="rs-section-title">${rsEsc(t.title)}</h3>
|
|
123
|
+
${t.html ? `<div class="rs-section-body">${t.html}</div>` : ''}</section>`).join('')
|
|
124
|
+
+ `</div>`,
|
|
125
|
+
removes: (d) => d.tabs.map(t => t.panel).filter(Boolean),
|
|
126
|
+
after: (d) => d.tabs.forEach(t => t.panel && t.panel.isConnected && t.panel.remove()),
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
antd_collapse: {
|
|
130
|
+
// flattening: the tab/section controls exist to reveal content this
|
|
131
|
+
// output reveals structurally, so they are subsumed, not lost
|
|
132
|
+
subsumes: true,
|
|
133
|
+
find: () => {
|
|
134
|
+
const roots = new Set();
|
|
135
|
+
document.querySelectorAll('.ant-collapse').forEach(c => {
|
|
136
|
+
if (c.querySelectorAll('.ant-collapse-header').length >= 2) roots.add(c);
|
|
137
|
+
});
|
|
138
|
+
return [...roots];
|
|
139
|
+
},
|
|
140
|
+
extract: (el) => {
|
|
141
|
+
const heads = [...el.querySelectorAll('.ant-collapse-header')];
|
|
142
|
+
const st = el.__rsCap || (el.__rsCap = { caps: {} });
|
|
143
|
+
let missing = null;
|
|
144
|
+
heads.forEach(h => {
|
|
145
|
+
const key = rsRendered(h);
|
|
146
|
+
if (!key) return;
|
|
147
|
+
const item = h.closest('.ant-collapse-item') || h.parentElement;
|
|
148
|
+
// antd renamed this between majors — v5 ships .ant-collapse-content, v6
|
|
149
|
+
// ships .ant-collapse-panel — so a single-name selector matched nothing on
|
|
150
|
+
// the current site and the rule reported zero instances on a page full of
|
|
151
|
+
// them. Accept both rather than pinning a version.
|
|
152
|
+
const body = item && item.querySelector('.ant-collapse-panel, .ant-collapse-content');
|
|
153
|
+
if (body && rsRendered(body)) st.caps[key] = { title: key, html: body.innerHTML, panel: body };
|
|
154
|
+
else if (!(key in st.caps) && !missing) missing = h;
|
|
155
|
+
});
|
|
156
|
+
if (missing) {
|
|
157
|
+
const tries = +(el.getAttribute('data-rs-probe') || 0);
|
|
158
|
+
if (tries < heads.length * 2 + 2) {
|
|
159
|
+
el.setAttribute('data-rs-probe', String(tries + 1));
|
|
160
|
+
missing.click();
|
|
161
|
+
return 'retry';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const secs = heads.map(h => st.caps[rsRendered(h)]).filter(Boolean);
|
|
165
|
+
return secs.length >= 2 ? { secs } : null;
|
|
166
|
+
},
|
|
167
|
+
render: (d) => `<div class="rs rs-sections">` + d.secs.map(t =>
|
|
168
|
+
`<section class="rs-section"><h3 class="rs-section-title">${rsEsc(t.title)}</h3>
|
|
169
|
+
<div class="rs-section-body">${t.html}</div></section>`).join('') + `</div>`,
|
|
170
|
+
removes: (d) => d.secs.map(t => t.panel),
|
|
171
|
+
after: (d) => d.secs.forEach(t => t.panel && t.panel.isConnected && t.panel.remove()),
|
|
172
|
+
},
|
|
173
|
+
})
|