coursecode 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.
Files changed (362) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -0
  3. package/THIRD_PARTY_NOTICES.md +22 -0
  4. package/bin/cli.js +331 -0
  5. package/framework/assets/logo-coursecode-black.svg +14 -0
  6. package/framework/assets/logo-coursecode-white.svg +14 -0
  7. package/framework/assets/logo-coursecode.svg +14 -0
  8. package/framework/css/01-base.css +160 -0
  9. package/framework/css/02-layout.css +499 -0
  10. package/framework/css/accessibility.css +834 -0
  11. package/framework/css/components/accordions.css +710 -0
  12. package/framework/css/components/assessments.css +520 -0
  13. package/framework/css/components/audio-player.css +570 -0
  14. package/framework/css/components/badges.css +80 -0
  15. package/framework/css/components/breadcrumbs.css +87 -0
  16. package/framework/css/components/buttons.css +707 -0
  17. package/framework/css/components/callouts.css +1280 -0
  18. package/framework/css/components/cards.css +475 -0
  19. package/framework/css/components/carousel.css +193 -0
  20. package/framework/css/components/checkbox-group.css +123 -0
  21. package/framework/css/components/checklist.css +203 -0
  22. package/framework/css/components/collapse.css +96 -0
  23. package/framework/css/components/comparison.css +33 -0
  24. package/framework/css/components/content-image.css +36 -0
  25. package/framework/css/components/document-gallery.css +425 -0
  26. package/framework/css/components/dropdown.css +115 -0
  27. package/framework/css/components/embed-frame.css +142 -0
  28. package/framework/css/components/engagement.css +412 -0
  29. package/framework/css/components/features.css +35 -0
  30. package/framework/css/components/flip-cards.css +253 -0
  31. package/framework/css/components/footer.css +353 -0
  32. package/framework/css/components/forms.css +294 -0
  33. package/framework/css/components/hero.css +216 -0
  34. package/framework/css/components/images.css +528 -0
  35. package/framework/css/components/interactive-timeline.css +274 -0
  36. package/framework/css/components/intro-cards.css +30 -0
  37. package/framework/css/components/lightbox.css +666 -0
  38. package/framework/css/components/loading.css +65 -0
  39. package/framework/css/components/modals.css +235 -0
  40. package/framework/css/components/notifications.css +107 -0
  41. package/framework/css/components/quote.css +150 -0
  42. package/framework/css/components/sidebar.css +684 -0
  43. package/framework/css/components/slide-header.css +52 -0
  44. package/framework/css/components/spinner.css +62 -0
  45. package/framework/css/components/stats.css +44 -0
  46. package/framework/css/components/steps.css +232 -0
  47. package/framework/css/components/tables.css +90 -0
  48. package/framework/css/components/tabs.css +347 -0
  49. package/framework/css/components/timeline.css +154 -0
  50. package/framework/css/components/toggle.css +95 -0
  51. package/framework/css/components/tooltip.css +226 -0
  52. package/framework/css/components/video-player.css +438 -0
  53. package/framework/css/design-tokens.css +707 -0
  54. package/framework/css/framework.css +86 -0
  55. package/framework/css/interactions/accessibility.css +75 -0
  56. package/framework/css/interactions/base.css +92 -0
  57. package/framework/css/interactions/drag-drop.css +295 -0
  58. package/framework/css/interactions/fill-in-the-blank.css +236 -0
  59. package/framework/css/interactions/hotspots.css +69 -0
  60. package/framework/css/interactions/index.css +45 -0
  61. package/framework/css/interactions/interactive-image.css +359 -0
  62. package/framework/css/interactions/likert.css +126 -0
  63. package/framework/css/interactions/matching.css +354 -0
  64. package/framework/css/interactions/numeric-input.css +78 -0
  65. package/framework/css/interactions/sequencing.css +378 -0
  66. package/framework/css/interactions/true-false.css +177 -0
  67. package/framework/css/layouts/article.css +258 -0
  68. package/framework/css/layouts/base.css +30 -0
  69. package/framework/css/layouts/canvas.css +38 -0
  70. package/framework/css/layouts/focused.css +236 -0
  71. package/framework/css/layouts/index.css +29 -0
  72. package/framework/css/layouts/presentation.css +191 -0
  73. package/framework/css/layouts/traditional.css +52 -0
  74. package/framework/css/responsive.css +439 -0
  75. package/framework/css/utilities/accessibility-utils.css +59 -0
  76. package/framework/css/utilities/animations.css +419 -0
  77. package/framework/css/utilities/borders.css +72 -0
  78. package/framework/css/utilities/colors.css +76 -0
  79. package/framework/css/utilities/container.css +46 -0
  80. package/framework/css/utilities/decorative.css +442 -0
  81. package/framework/css/utilities/display.css +257 -0
  82. package/framework/css/utilities/flexbox.css +80 -0
  83. package/framework/css/utilities/grid.css +69 -0
  84. package/framework/css/utilities/icons.css +534 -0
  85. package/framework/css/utilities/lists.css +190 -0
  86. package/framework/css/utilities/spacing.css +167 -0
  87. package/framework/css/utilities/tables.css +81 -0
  88. package/framework/css/utilities/typography.css +159 -0
  89. package/framework/css/utilities/visibility.css +117 -0
  90. package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
  91. package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
  92. package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
  93. package/framework/docs/DATA_MODEL.md +409 -0
  94. package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
  95. package/framework/docs/USER_GUIDE.md +583 -0
  96. package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
  97. package/framework/docs/examples/cloudflare-data-worker.js +102 -0
  98. package/framework/docs/examples/cloudflare-error-worker.js +228 -0
  99. package/framework/index.html +175 -0
  100. package/framework/js/app/AppActions.js +410 -0
  101. package/framework/js/app/AppState.js +225 -0
  102. package/framework/js/app/AppUI.js +616 -0
  103. package/framework/js/assessment/AssessmentActions.js +615 -0
  104. package/framework/js/assessment/AssessmentFactory.js +471 -0
  105. package/framework/js/assessment/AssessmentState.js +322 -0
  106. package/framework/js/assessment/AssessmentUI.js +451 -0
  107. package/framework/js/automation/api-engagement.js +196 -0
  108. package/framework/js/automation/api-interactions.js +167 -0
  109. package/framework/js/automation/api.js +242 -0
  110. package/framework/js/automation/index.js +41 -0
  111. package/framework/js/components/interactions/drag-drop.js +884 -0
  112. package/framework/js/components/interactions/fill-in.js +535 -0
  113. package/framework/js/components/interactions/hotspot.js +702 -0
  114. package/framework/js/components/interactions/interaction-base.js +511 -0
  115. package/framework/js/components/interactions/likert.js +301 -0
  116. package/framework/js/components/interactions/matching.js +699 -0
  117. package/framework/js/components/interactions/multiple-choice.js +377 -0
  118. package/framework/js/components/interactions/numeric.js +271 -0
  119. package/framework/js/components/interactions/sequencing.js +423 -0
  120. package/framework/js/components/interactions/true-false.js +241 -0
  121. package/framework/js/components/ui-components/accordion.js +442 -0
  122. package/framework/js/components/ui-components/alert.js +88 -0
  123. package/framework/js/components/ui-components/audio-player.js +1193 -0
  124. package/framework/js/components/ui-components/callout.js +121 -0
  125. package/framework/js/components/ui-components/carousel.js +145 -0
  126. package/framework/js/components/ui-components/checkbox-group.js +87 -0
  127. package/framework/js/components/ui-components/checklist.js +40 -0
  128. package/framework/js/components/ui-components/collapse.js +114 -0
  129. package/framework/js/components/ui-components/comparison.js +30 -0
  130. package/framework/js/components/ui-components/conditional-display.js +150 -0
  131. package/framework/js/components/ui-components/content-image.js +41 -0
  132. package/framework/js/components/ui-components/dropdown.js +262 -0
  133. package/framework/js/components/ui-components/embed-frame.js +274 -0
  134. package/framework/js/components/ui-components/features.js +33 -0
  135. package/framework/js/components/ui-components/flip-card.js +230 -0
  136. package/framework/js/components/ui-components/form-validator.js +76 -0
  137. package/framework/js/components/ui-components/hero.js +49 -0
  138. package/framework/js/components/ui-components/index.js +12 -0
  139. package/framework/js/components/ui-components/interactive-image.js +235 -0
  140. package/framework/js/components/ui-components/interactive-timeline.js +285 -0
  141. package/framework/js/components/ui-components/intro-cards.js +35 -0
  142. package/framework/js/components/ui-components/lightbox.js +652 -0
  143. package/framework/js/components/ui-components/modal.js +386 -0
  144. package/framework/js/components/ui-components/notifications.js +145 -0
  145. package/framework/js/components/ui-components/progress.js +88 -0
  146. package/framework/js/components/ui-components/quote.js +41 -0
  147. package/framework/js/components/ui-components/stats.js +33 -0
  148. package/framework/js/components/ui-components/steps.js +41 -0
  149. package/framework/js/components/ui-components/tabs.js +255 -0
  150. package/framework/js/components/ui-components/timeline.js +42 -0
  151. package/framework/js/components/ui-components/toggle-group.js +73 -0
  152. package/framework/js/components/ui-components/tooltip.js +458 -0
  153. package/framework/js/components/ui-components/value-display.js +133 -0
  154. package/framework/js/components/ui-components/video-player.js +686 -0
  155. package/framework/js/core/component-catalog.js +121 -0
  156. package/framework/js/core/event-bus.js +178 -0
  157. package/framework/js/core/interaction-catalog.js +149 -0
  158. package/framework/js/dev/runtime-linter.js +1725 -0
  159. package/framework/js/drivers/cmi5-driver.js +768 -0
  160. package/framework/js/drivers/driver-factory.js +77 -0
  161. package/framework/js/drivers/driver-interface.js +110 -0
  162. package/framework/js/drivers/http-driver-base.js +241 -0
  163. package/framework/js/drivers/lti-driver.js +508 -0
  164. package/framework/js/drivers/proxy-driver.js +444 -0
  165. package/framework/js/drivers/scorm-12-driver.js +560 -0
  166. package/framework/js/drivers/scorm-2004-driver.js +775 -0
  167. package/framework/js/drivers/scorm-driver-base.js +112 -0
  168. package/framework/js/engagement/engagement-manager.js +404 -0
  169. package/framework/js/engagement/engagement-progress.js +191 -0
  170. package/framework/js/engagement/engagement-trackers.js +215 -0
  171. package/framework/js/engagement/requirement-strategies.js +268 -0
  172. package/framework/js/main.js +727 -0
  173. package/framework/js/managers/accessibility-manager.js +499 -0
  174. package/framework/js/managers/assessment-manager.js +230 -0
  175. package/framework/js/managers/audio-manager.js +944 -0
  176. package/framework/js/managers/comment-manager.js +88 -0
  177. package/framework/js/managers/flag-manager.js +86 -0
  178. package/framework/js/managers/interaction-manager.js +254 -0
  179. package/framework/js/managers/interaction-registry.js +96 -0
  180. package/framework/js/managers/objective-manager.js +423 -0
  181. package/framework/js/managers/score-manager.js +441 -0
  182. package/framework/js/managers/video-manager.js +536 -0
  183. package/framework/js/navigation/Breadcrumbs.js +234 -0
  184. package/framework/js/navigation/NavigationActions.js +1132 -0
  185. package/framework/js/navigation/NavigationState.js +276 -0
  186. package/framework/js/navigation/NavigationUI.js +574 -0
  187. package/framework/js/navigation/document-gallery.js +357 -0
  188. package/framework/js/navigation/navigation-helpers.js +175 -0
  189. package/framework/js/navigation/navigation-validators.js +174 -0
  190. package/framework/js/state/index.js +8 -0
  191. package/framework/js/state/lms-connection.js +482 -0
  192. package/framework/js/state/lms-error-utils.js +58 -0
  193. package/framework/js/state/state-commits.js +200 -0
  194. package/framework/js/state/state-domains.js +86 -0
  195. package/framework/js/state/state-manager.js +502 -0
  196. package/framework/js/state/state-validation.js +311 -0
  197. package/framework/js/state/transaction-log.js +41 -0
  198. package/framework/js/state/xapi-statement-service.js +325 -0
  199. package/framework/js/utilities/access-control.js +99 -0
  200. package/framework/js/utilities/breakpoint-manager.js +315 -0
  201. package/framework/js/utilities/canvas-slide.js +35 -0
  202. package/framework/js/utilities/conditional-display.js +388 -0
  203. package/framework/js/utilities/course-channel.js +214 -0
  204. package/framework/js/utilities/course-helpers.js +420 -0
  205. package/framework/js/utilities/data-reporter.js +273 -0
  206. package/framework/js/utilities/error-reporter.js +313 -0
  207. package/framework/js/utilities/hotspot-helper.js +341 -0
  208. package/framework/js/utilities/icons.js +348 -0
  209. package/framework/js/utilities/logger.js +92 -0
  210. package/framework/js/utilities/markdown-renderer.js +45 -0
  211. package/framework/js/utilities/scroll-tracker.js +68 -0
  212. package/framework/js/utilities/ui-initializer.js +146 -0
  213. package/framework/js/utilities/utilities.js +293 -0
  214. package/framework/js/utilities/view-manager.js +227 -0
  215. package/framework/js/validation/html-validators.js +422 -0
  216. package/framework/js/validation/scorm-validators.js +438 -0
  217. package/framework/js/vendor/pipwerks.js +931 -0
  218. package/framework/scripts/generate-narration.js +629 -0
  219. package/framework/scripts/tts-providers/azure-provider.js +178 -0
  220. package/framework/scripts/tts-providers/base-provider.js +81 -0
  221. package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
  222. package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
  223. package/framework/scripts/tts-providers/google-provider.js +272 -0
  224. package/framework/scripts/tts-providers/index.js +158 -0
  225. package/framework/scripts/tts-providers/openai-provider.js +143 -0
  226. package/framework/version.json +63 -0
  227. package/lib/authoring-api.js +919 -0
  228. package/lib/build-linter.js +450 -0
  229. package/lib/build-packaging.js +186 -0
  230. package/lib/build.js +88 -0
  231. package/lib/cloud.js +691 -0
  232. package/lib/convert.js +341 -0
  233. package/lib/course-parser.js +936 -0
  234. package/lib/course-writer.js +258 -0
  235. package/lib/create.js +248 -0
  236. package/lib/css-index.js +237 -0
  237. package/lib/dev.js +51 -0
  238. package/lib/export-content.js +1246 -0
  239. package/lib/headless-browser.js +413 -0
  240. package/lib/import.js +377 -0
  241. package/lib/index.js +80 -0
  242. package/lib/info.js +79 -0
  243. package/lib/interaction-formatters.js +568 -0
  244. package/lib/manifest/cmi5-manifest.js +63 -0
  245. package/lib/manifest/lti-tool-config.js +53 -0
  246. package/lib/manifest/manifest-factory.js +99 -0
  247. package/lib/manifest/scorm-12-manifest.js +61 -0
  248. package/lib/manifest/scorm-2004-manifest.js +94 -0
  249. package/lib/manifest/scorm-proxy-manifest.js +104 -0
  250. package/lib/manifest-parser.js +96 -0
  251. package/lib/mcp-prompts.js +753 -0
  252. package/lib/mcp-server.js +316 -0
  253. package/lib/narration.js +53 -0
  254. package/lib/pdf-structure.js +142 -0
  255. package/lib/preview-export.js +231 -0
  256. package/lib/preview-routes-api.js +662 -0
  257. package/lib/preview-routes-editing.js +159 -0
  258. package/lib/preview-routes-lms.js +230 -0
  259. package/lib/preview-server.js +564 -0
  260. package/lib/project-utils.js +269 -0
  261. package/lib/proxy-templates/proxy.html +68 -0
  262. package/lib/proxy-templates/scorm-bridge.js +112 -0
  263. package/lib/scaffold.js +193 -0
  264. package/lib/schema-extractor.js +361 -0
  265. package/lib/slide-source-editor.js +586 -0
  266. package/lib/stub-player/app-viewer.js +195 -0
  267. package/lib/stub-player/app.js +370 -0
  268. package/lib/stub-player/catalog-panel.js +312 -0
  269. package/lib/stub-player/config-panel.js +1303 -0
  270. package/lib/stub-player/content-generator.js +586 -0
  271. package/lib/stub-player/content-viewer.js +173 -0
  272. package/lib/stub-player/debug-panel.js +420 -0
  273. package/lib/stub-player/edit-mode.js +922 -0
  274. package/lib/stub-player/edit-utils.js +400 -0
  275. package/lib/stub-player/header-bar.js +354 -0
  276. package/lib/stub-player/interaction-editor.js +210 -0
  277. package/lib/stub-player/interactions-panel.js +565 -0
  278. package/lib/stub-player/lms-api.js +1094 -0
  279. package/lib/stub-player/login-screen.js +74 -0
  280. package/lib/stub-player/outline-mode.js +689 -0
  281. package/lib/stub-player/styles/_assessments-panel.css +245 -0
  282. package/lib/stub-player/styles/_base.css +89 -0
  283. package/lib/stub-player/styles/_catalog-icons.css +96 -0
  284. package/lib/stub-player/styles/_catalog-panel.css +291 -0
  285. package/lib/stub-player/styles/_config-panel.css +636 -0
  286. package/lib/stub-player/styles/_content-viewer.css +834 -0
  287. package/lib/stub-player/styles/_debug-panel.css +576 -0
  288. package/lib/stub-player/styles/_edit-mode.css +128 -0
  289. package/lib/stub-player/styles/_header-bar.css +343 -0
  290. package/lib/stub-player/styles/_interaction-editor.css +140 -0
  291. package/lib/stub-player/styles/_interactions-panel.css +1038 -0
  292. package/lib/stub-player/styles/_login-screen.css +102 -0
  293. package/lib/stub-player/styles/_outline-mode.css +752 -0
  294. package/lib/stub-player/styles.css +15 -0
  295. package/lib/stub-player.js +160 -0
  296. package/lib/test-data-reporting.js +176 -0
  297. package/lib/test-error-reporting.js +146 -0
  298. package/lib/token.js +86 -0
  299. package/lib/upgrade.js +257 -0
  300. package/lib/validation-rules.js +517 -0
  301. package/lib/vite-plugin-content-discovery.js +296 -0
  302. package/package.json +108 -0
  303. package/schemas/XMLSchema.dtd +402 -0
  304. package/schemas/adlcp_v1p3.xsd +111 -0
  305. package/schemas/adlnav_v1p3.xsd +61 -0
  306. package/schemas/adlseq_v1p3.xsd +93 -0
  307. package/schemas/common/anyElement.xsd +27 -0
  308. package/schemas/common/dataTypes.xsd +138 -0
  309. package/schemas/common/elementNames.xsd +767 -0
  310. package/schemas/common/elementTypes.xsd +786 -0
  311. package/schemas/common/rootElement.xsd +31 -0
  312. package/schemas/common/vocabTypes.xsd +345 -0
  313. package/schemas/common/vocabValues.xsd +257 -0
  314. package/schemas/datatypes.dtd +203 -0
  315. package/schemas/ims_xml.xsd +35 -0
  316. package/schemas/imscp_v1p1.xsd +368 -0
  317. package/schemas/imsss_v1p0.xsd +67 -0
  318. package/schemas/imsss_v1p0auxresource.xsd +19 -0
  319. package/schemas/imsss_v1p0control.xsd +20 -0
  320. package/schemas/imsss_v1p0delivery.xsd +17 -0
  321. package/schemas/imsss_v1p0limit.xsd +47 -0
  322. package/schemas/imsss_v1p0objective.xsd +67 -0
  323. package/schemas/imsss_v1p0random.xsd +16 -0
  324. package/schemas/imsss_v1p0rollup.xsd +46 -0
  325. package/schemas/imsss_v1p0seqrule.xsd +108 -0
  326. package/schemas/imsss_v1p0util.xsd +94 -0
  327. package/schemas/license.txt +17 -0
  328. package/schemas/lom.xsd +102 -0
  329. package/schemas/lomCustom.xsd +62 -0
  330. package/schemas/lomLoose.xsd +62 -0
  331. package/schemas/lomStrict.xsd +62 -0
  332. package/schemas/xml.xsd +81 -0
  333. package/template/.env.example +92 -0
  334. package/template/course/assets/audio/example-intro.mp3 +0 -0
  335. package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
  336. package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
  337. package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
  338. package/template/course/assets/docs/example_md_1.md +39 -0
  339. package/template/course/assets/docs/example_md_2.md +41 -0
  340. package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
  341. package/template/course/assets/docs/example_pdf_2.pdf +0 -0
  342. package/template/course/assets/images/course-architecture.svg +36 -0
  343. package/template/course/assets/images/logo.svg +14 -0
  344. package/template/course/assets/widgets/counter-demo.html +190 -0
  345. package/template/course/assets/widgets/gravity-painter.html +384 -0
  346. package/template/course/course-config.js +539 -0
  347. package/template/course/icons.js +19 -0
  348. package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
  349. package/template/course/slides/example-course-structure.js +138 -0
  350. package/template/course/slides/example-final-exam.js +144 -0
  351. package/template/course/slides/example-finishing.js +127 -0
  352. package/template/course/slides/example-interactions-showcase.js +615 -0
  353. package/template/course/slides/example-preview-tour.js +129 -0
  354. package/template/course/slides/example-remedial.js +143 -0
  355. package/template/course/slides/example-summary.js +103 -0
  356. package/template/course/slides/example-ui-showcase.js +1805 -0
  357. package/template/course/slides/example-welcome.js +123 -0
  358. package/template/course/slides/example-workflow.js +140 -0
  359. package/template/course/theme.css +165 -0
  360. package/template/eslint.config.js +47 -0
  361. package/template/package.json +28 -0
  362. package/template/vite.config.js +339 -0
@@ -0,0 +1,884 @@
1
+ import { logger } from '../../utilities/logger.js';
2
+ import {
3
+ validateAgainstSchema,
4
+ createInteractionEventHandler,
5
+ renderInteractionControls,
6
+ displayFeedback,
7
+ clearFeedback,
8
+ normalizeInitialResponse,
9
+ validateContainer,
10
+ escapeCssSelector,
11
+ registerCoreInteraction,
12
+ parseResponse
13
+ } from './interaction-base.js';
14
+
15
+ // Metadata for drag-drop interaction type
16
+ export const metadata = {
17
+ creator: 'createDragDropQuestion',
18
+ scormType: 'other',
19
+ showCheckAnswer: true,
20
+ isAnswered: (response) => {
21
+ if (!response || typeof response !== 'object') return false;
22
+ return Object.keys(response).length > 0;
23
+ },
24
+ getCorrectAnswer: (config) => {
25
+ const correctPlacements = {};
26
+ if (config.dropZones && Array.isArray(config.dropZones)) {
27
+ config.dropZones.forEach(zone => {
28
+ if (zone.accepts && Array.isArray(zone.accepts)) {
29
+ zone.accepts.forEach(itemId => {
30
+ correctPlacements[itemId] = zone.id;
31
+ });
32
+ }
33
+ });
34
+ }
35
+ return JSON.stringify(correctPlacements);
36
+ },
37
+ formatCorrectAnswer: (_question, _correctAnswer) => {
38
+ return '<p class="correct-item">See correct placements above</p>';
39
+ },
40
+ formatUserResponse: (question, response) => {
41
+ try {
42
+ const placements = typeof response === 'string' ? JSON.parse(response) : response;
43
+ const count = Object.keys(placements).length;
44
+ return `<p class="response-item">${count} item(s) placed</p>`;
45
+ } catch (_error) {
46
+ return `<p class="response-item">${response}</p>`;
47
+ }
48
+ }
49
+ };
50
+
51
+ // Schema for validation, linting, and AI-assisted authoring
52
+ export const schema = {
53
+ type: 'drag-drop',
54
+ description: 'Drag items into categorized drop zones',
55
+ scormType: 'matching',
56
+ example: `<div class="interaction drag-drop" data-interaction-id="demo-dd">
57
+ <div class="question-prompt"><h3>Match items to their categories</h3></div>
58
+ <div class="drag-drop-container">
59
+ <div class="drag-items"><h4>Drag these items:</h4>
60
+ <div class="drag-item" draggable="true" data-item-id="a">HTML</div>
61
+ <div class="drag-item" draggable="true" data-item-id="b">CSS</div>
62
+ <div class="drag-item" draggable="true" data-item-id="c">JavaScript</div>
63
+ </div>
64
+ <div class="drop-zones"><h4>Drop into correct zones:</h4>
65
+ <div class="drop-zone" data-zone-id="structure"><div class="zone-label">Structure</div><div class="zone-content"></div></div>
66
+ <div class="drop-zone" data-zone-id="style"><div class="zone-label">Styling</div><div class="zone-content"></div></div>
67
+ <div class="drop-zone" data-zone-id="behavior"><div class="zone-label">Behavior</div><div class="zone-content"></div></div>
68
+ </div>
69
+ </div>
70
+ <div class="interaction-controls"><button class="btn btn-primary" disabled>Check Answer</button></div>
71
+ </div>`,
72
+ properties: {
73
+ items: {
74
+ type: 'array',
75
+ required: true,
76
+ minItems: 1,
77
+ description: 'Draggable items',
78
+ itemSchema: {
79
+ id: { type: 'string', required: true },
80
+ text: { type: 'string', required: true },
81
+ correctZone: { type: 'string', required: true }
82
+ }
83
+ },
84
+ dropZones: {
85
+ type: 'array',
86
+ required: true,
87
+ minItems: 1,
88
+ description: 'Drop target zones',
89
+ itemSchema: {
90
+ id: { type: 'string', required: true },
91
+ label: { type: 'string', required: true }
92
+ }
93
+ }
94
+ }
95
+ };
96
+
97
+ export function createDragDropQuestion(config) {
98
+ validateAgainstSchema(config, schema);
99
+
100
+ const { id, prompt, items, dropZones, controlled = false } = config;
101
+
102
+ // Validate items and dropZones arrays
103
+ if (!Array.isArray(items) || items.length === 0) {
104
+ throw new Error(`Drag-drop question "${id}" must have at least one item`);
105
+ }
106
+
107
+ if (!Array.isArray(dropZones) || dropZones.length === 0) {
108
+ throw new Error(`Drag-drop question "${id}" must have at least one drop zone`);
109
+ }
110
+
111
+ let _container = null;
112
+
113
+ const questionObj = {
114
+ id,
115
+ type: 'drag-drop',
116
+
117
+ render: (container, initialResponse = null) => {
118
+ validateContainer(container, id);
119
+ _container = container;
120
+
121
+ // Parse initial response as object
122
+ const initialValue = normalizeInitialResponse(initialResponse);
123
+ const initialPlacements = parseResponse(initialValue, 'object') || {};
124
+
125
+ let html = `
126
+ <div class="interaction drag-drop" data-interaction-id="${id}">
127
+ <div class="question-prompt">
128
+ <h3>${prompt}</h3>
129
+ </div>
130
+ <div class="drag-drop-container">
131
+ <div class="drag-items" data-droppable="true">
132
+ <h4>Drag these items:</h4>
133
+ `;
134
+
135
+ items.forEach((item, index) => {
136
+ const isPlaced = initialPlacements[item.id] !== undefined;
137
+
138
+ html += `
139
+ <div
140
+ class="drag-item${isPlaced ? ' hidden' : ''}"
141
+ draggable="true"
142
+ data-item-id="${item.id}"
143
+ data-index="${index}"
144
+ tabindex="0"
145
+ role="button"
146
+ aria-grabbed="false"
147
+ data-testid="${id}-drag-item-${item.id}"
148
+ >
149
+ ${item.content}
150
+ </div>
151
+ `;
152
+ });
153
+
154
+ html += `
155
+ </div>
156
+ <div class="drop-zones">
157
+ <h4>Drop into correct zones:</h4>
158
+ `;
159
+
160
+ dropZones.forEach((zone) => {
161
+ const placedItemIds = Object.keys(initialPlacements).filter(itemId => initialPlacements[itemId] === zone.id);
162
+ const maxItems = zone.maxItems || 1; // Default to 1 if not specified
163
+
164
+ html += `
165
+ <div
166
+ class="drop-zone"
167
+ data-zone-id="${zone.id}"
168
+ data-accepts="${zone.accepts.join(',')}"
169
+ data-max-items="${maxItems}"
170
+ role="region"
171
+ aria-label="${zone.label}"
172
+ tabindex="0"
173
+ data-testid="${id}-drop-zone-${zone.id}"
174
+ >
175
+ <div class="zone-label">${zone.label}</div>
176
+ <div class="zone-content">`;
177
+
178
+ // Add placed items to the zone
179
+ placedItemIds.forEach(itemId => {
180
+ const item = items.find(i => i.id === itemId);
181
+ if (item) {
182
+ html += `
183
+ <div class="drag-item dropped" data-item-id="${item.id}" draggable="true" data-testid="${id}-dropped-item-${item.id}">
184
+ ${item.content}
185
+ <button type="button" class="remove-item" data-action="remove-item" data-item-id="${item.id}" aria-label="Remove ${item.content}" title="Remove this item" data-testid="${id}-remove-${item.id}">×</button>
186
+ </div>
187
+ `;
188
+ }
189
+ });
190
+
191
+ html += `
192
+ </div>
193
+ </div>
194
+ `;
195
+ });
196
+
197
+ html += `
198
+ </div>
199
+ </div>
200
+ ${renderInteractionControls(id, controlled)}
201
+ <div class="overall-feedback" id="${id}_overall_feedback" aria-live="polite"></div>
202
+ </div>
203
+ `;
204
+
205
+ container.innerHTML = html;
206
+
207
+ // Setup drag-drop interaction
208
+ setupDragDropInteraction(container, questionObj, initialPlacements);
209
+
210
+ // Attach event handler only in uncontrolled mode
211
+ if (!controlled) {
212
+ const correctPattern = JSON.stringify(dropZones.reduce((acc, zone) => {
213
+ zone.accepts.forEach(itemId => acc[itemId] = zone.id);
214
+ return acc;
215
+ }, {}));
216
+
217
+ container.addEventListener('click', createInteractionEventHandler(questionObj, {
218
+ ...config,
219
+ scormType: 'other',
220
+ correctPattern
221
+ }));
222
+ }
223
+
224
+ // Add direct event listeners to remove buttons to prevent drag interference
225
+ const handleRemoveClick = (e) => {
226
+ if (e.target.classList.contains('remove-item')) {
227
+ e.stopPropagation();
228
+ e.preventDefault();
229
+ const itemId = e.target.dataset.itemId;
230
+ if (itemId) {
231
+ removeItemFromZone(container, itemId);
232
+ }
233
+ }
234
+ };
235
+
236
+ // Listen on multiple events to ensure reliability
237
+ container.addEventListener('click', handleRemoveClick, true);
238
+ container.addEventListener('mousedown', handleRemoveClick, true);
239
+ container.addEventListener('touchstart', handleRemoveClick, { capture: true, passive: false });
240
+ },
241
+
242
+ evaluate: (placements) => {
243
+ if (!placements || typeof placements !== 'object') {
244
+ return {
245
+ score: 0,
246
+ correct: false,
247
+ results: [],
248
+ response: JSON.stringify({}),
249
+ error: 'Invalid placements format'
250
+ };
251
+ }
252
+
253
+ let correct = 0;
254
+ const results = [];
255
+
256
+ Object.entries(placements).forEach(([itemId, zoneId]) => {
257
+ const zone = dropZones.find(z => z.id === zoneId);
258
+ const isCorrect = zone && zone.accepts.includes(itemId);
259
+ if (isCorrect) correct++;
260
+ results.push({ itemId, zoneId, correct: isCorrect });
261
+ });
262
+
263
+ return {
264
+ score: correct / items.length,
265
+ correct: correct === items.length,
266
+ results,
267
+ response: JSON.stringify(placements)
268
+ };
269
+ },
270
+
271
+ checkAnswer: () => {
272
+ validateContainer(_container, id);
273
+
274
+ const placements = questionObj.getResponse();
275
+ const evaluation = questionObj.evaluate(placements);
276
+
277
+ if (evaluation.correct) {
278
+ displayFeedback(_container, id, 'Excellent! All items are in the correct zones.', 'correct');
279
+ } else {
280
+ displayFeedback(_container, id, `${Math.round(evaluation.score * 100)}% correct. Review your placements.`, 'incorrect');
281
+ }
282
+
283
+ return evaluation;
284
+ },
285
+
286
+ reset: () => {
287
+ validateContainer(_container, id);
288
+
289
+ const dragItems = _container.querySelectorAll('.drag-item');
290
+ dragItems.forEach(item => {
291
+ item.style.display = '';
292
+ item.classList.remove('keyboard-selected', 'dropped');
293
+ });
294
+
295
+ const zoneContents = _container.querySelectorAll('.drop-zone .zone-content');
296
+ zoneContents.forEach(zone => zone.innerHTML = '');
297
+
298
+ clearFeedback(_container, id);
299
+
300
+ // Reset internal state
301
+ const state = _container._dragDropState;
302
+ if (state) {
303
+ state.placements = {};
304
+ state.selectedForDrop = null;
305
+ }
306
+ },
307
+
308
+ getResponse: () => {
309
+ validateContainer(_container, id);
310
+
311
+ const placements = {};
312
+ const zones = _container.querySelectorAll('.drop-zone');
313
+
314
+ zones.forEach(zone => {
315
+ const droppedItems = zone.querySelectorAll('.drag-item.dropped');
316
+ droppedItems.forEach(item => {
317
+ placements[item.dataset.itemId] = zone.dataset.zoneId;
318
+ });
319
+ });
320
+
321
+ return placements;
322
+ },
323
+
324
+ setResponse: (placements) => {
325
+ validateContainer(_container, id);
326
+
327
+ if (!placements || typeof placements !== 'object') {
328
+ throw new Error(`setResponse expects an object for drag-drop question "${id}"`);
329
+ }
330
+
331
+ // Reset to clean state
332
+ const dragItems = _container.querySelectorAll('.drag-item');
333
+ dragItems.forEach(item => item.style.display = '');
334
+
335
+ const zones = _container.querySelectorAll('.drop-zone .zone-content');
336
+ zones.forEach(zone => zone.innerHTML = '');
337
+
338
+ // Apply placements
339
+ Object.keys(placements).forEach(itemId => {
340
+ const zoneId = placements[itemId];
341
+ const item = _container.querySelector(`.drag-item[data-item-id="${escapeCssSelector(itemId)}"]`);
342
+ const zone = _container.querySelector(`.drop-zone[data-zone-id="${escapeCssSelector(zoneId)}"] .zone-content`);
343
+
344
+ if (item && zone) {
345
+ const clonedItem = item.cloneNode(true);
346
+ clonedItem.classList.add('dropped');
347
+ clonedItem.draggable = false;
348
+ zone.appendChild(clonedItem);
349
+ item.style.display = 'none';
350
+ }
351
+ });
352
+
353
+ // Update internal state
354
+ const state = _container._dragDropState;
355
+ if (state) {
356
+ state.placements = { ...placements };
357
+ }
358
+ },
359
+
360
+ getCorrectAnswer: () => {
361
+ const correctPlacements = {};
362
+ dropZones.forEach(zone => {
363
+ if (zone.accepts && Array.isArray(zone.accepts)) {
364
+ zone.accepts.forEach(itemId => {
365
+ correctPlacements[itemId] = zone.id;
366
+ });
367
+ }
368
+ });
369
+ return correctPlacements;
370
+ }
371
+ };
372
+
373
+ // For uncontrolled interactions, register with the central registry for lifecycle mgmt
374
+ if (!controlled) {
375
+ registerCoreInteraction(config, questionObj);
376
+ }
377
+
378
+ return questionObj;
379
+ }
380
+
381
+ /**
382
+ * Sets up drag-drop interaction with native HTML5 drag-and-drop, touch, and keyboard support
383
+ */
384
+ function setupDragDropInteraction(container, questionObj, initialPlacements = {}) {
385
+ // Store state on container element
386
+ container._dragDropState = {
387
+ placements: { ...initialPlacements },
388
+ draggedElement: null,
389
+ selectedForDrop: null,
390
+ // Touch-specific state
391
+ touchDragElement: null,
392
+ touchClone: null,
393
+ touchStartX: 0,
394
+ touchStartY: 0,
395
+ touchOffsetX: 0,
396
+ touchOffsetY: 0
397
+ };
398
+
399
+ const state = container._dragDropState;
400
+
401
+ // Lock the drag-items area height to prevent shrinking when items are removed
402
+ const dragItemsArea = container.querySelector('.drag-items');
403
+ if (dragItemsArea) {
404
+ // Use requestAnimationFrame to ensure layout is complete before measuring
405
+ requestAnimationFrame(() => {
406
+ const currentHeight = dragItemsArea.offsetHeight;
407
+ dragItemsArea.style.minHeight = `${currentHeight}px`;
408
+ });
409
+ }
410
+
411
+ // Prevent drag from starting when clicking remove buttons
412
+ const preventDragOnButton = (e) => {
413
+ if (e.target.classList.contains('remove-item')) {
414
+ e.stopPropagation();
415
+ // Temporarily disable draggable on the parent
416
+ const dragItem = e.target.closest('.drag-item');
417
+ if (dragItem) {
418
+ dragItem.draggable = false;
419
+ setTimeout(() => { dragItem.draggable = true; }, 100);
420
+ }
421
+ }
422
+ };
423
+
424
+ container.addEventListener('mousedown', preventDragOnButton, true);
425
+ container.addEventListener('touchstart', preventDragOnButton, { capture: true, passive: false });
426
+
427
+ // Setup drag-and-drop event listeners
428
+ const dragItems = container.querySelectorAll('.drag-item');
429
+ dragItems.forEach(item => {
430
+ item.addEventListener('dragstart', (e) => {
431
+ // Prevent drag if clicking on remove button
432
+ if (e.target.classList.contains('remove-item') || e.target.closest('.remove-item')) {
433
+ e.preventDefault();
434
+ return;
435
+ }
436
+ state.draggedElement = e.currentTarget;
437
+ e.currentTarget.setAttribute('aria-grabbed', 'true');
438
+ e.dataTransfer.effectAllowed = 'move';
439
+ e.dataTransfer.setData('text/plain', e.currentTarget.dataset.itemId);
440
+ });
441
+
442
+ item.addEventListener('dragend', (e) => {
443
+ e.target.setAttribute('aria-grabbed', 'false');
444
+ });
445
+
446
+ // Keyboard support for items
447
+ item.addEventListener('keydown', (e) => {
448
+ if (e.key === 'Enter' || e.key === ' ') {
449
+ e.preventDefault();
450
+ container.querySelectorAll('.drag-item').forEach(i => i.classList.remove('keyboard-selected'));
451
+ state.selectedForDrop = e.currentTarget;
452
+ e.currentTarget.classList.add('keyboard-selected');
453
+ }
454
+ });
455
+
456
+ // Touch support for items
457
+ setupTouchDragForItem(item, container, state);
458
+ });
459
+
460
+ // Setup drop zones
461
+ const dropZones = container.querySelectorAll('.drop-zone');
462
+ dropZones.forEach(zone => {
463
+ zone.addEventListener('dragover', (e) => {
464
+ e.preventDefault();
465
+ const zoneContent = e.currentTarget.querySelector('.zone-content');
466
+ const maxItems = parseInt(e.currentTarget.dataset.maxItems) || 1;
467
+ const currentItems = zoneContent ? zoneContent.querySelectorAll('.drag-item.dropped').length : 0;
468
+ const isFull = currentItems >= maxItems;
469
+
470
+ e.dataTransfer.dropEffect = isFull ? 'none' : 'move';
471
+ e.currentTarget.classList.add('drag-over');
472
+ e.currentTarget.classList.toggle('zone-full', isFull);
473
+ });
474
+
475
+ zone.addEventListener('dragleave', (e) => {
476
+ e.currentTarget.classList.remove('drag-over', 'zone-full');
477
+ });
478
+
479
+ zone.addEventListener('drop', (e) => {
480
+ e.preventDefault();
481
+ e.currentTarget.classList.remove('drag-over', 'zone-full');
482
+ if (state.draggedElement) {
483
+ performDrop(container, state, state.draggedElement, e.currentTarget);
484
+ }
485
+ });
486
+
487
+ // Keyboard support for zones
488
+ zone.addEventListener('keydown', (e) => {
489
+ if ((e.key === 'Enter' || e.key === ' ') && state.selectedForDrop) {
490
+ e.preventDefault();
491
+ performDrop(container, state, state.selectedForDrop, e.currentTarget);
492
+ }
493
+ });
494
+ });
495
+
496
+ // Setup drag items area as a drop zone (to drag items back)
497
+ if (dragItemsArea) {
498
+ dragItemsArea.addEventListener('dragover', (e) => {
499
+ e.preventDefault();
500
+ e.dataTransfer.dropEffect = 'move';
501
+ e.currentTarget.classList.add('drag-over');
502
+ });
503
+
504
+ dragItemsArea.addEventListener('dragleave', (e) => {
505
+ e.currentTarget.classList.remove('drag-over');
506
+ });
507
+
508
+ dragItemsArea.addEventListener('drop', (e) => {
509
+ e.preventDefault();
510
+ e.currentTarget.classList.remove('drag-over');
511
+ if (state.draggedElement && state.draggedElement.classList.contains('dropped')) {
512
+ const itemId = state.draggedElement.dataset.itemId;
513
+ removeItemFromZone(container, itemId);
514
+ }
515
+ });
516
+
517
+ // Keyboard support to return items
518
+ dragItemsArea.addEventListener('keydown', (e) => {
519
+ if ((e.key === 'Enter' || e.key === ' ') && state.selectedForDrop && state.selectedForDrop.classList.contains('dropped')) {
520
+ e.preventDefault();
521
+ const itemId = state.selectedForDrop.dataset.itemId;
522
+ removeItemFromZone(container, itemId);
523
+ state.selectedForDrop = null;
524
+ }
525
+ });
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Performs a drop operation, moving an item to a zone
531
+ */
532
+ function performDrop(container, state, item, zone) {
533
+ const itemId = item.dataset.itemId;
534
+ const zoneId = zone.dataset.zoneId;
535
+ const zoneContent = zone.querySelector('.zone-content');
536
+
537
+ if (!zoneContent) {
538
+ throw new Error('Drop zone content element not found');
539
+ }
540
+
541
+ // Check max items limit
542
+ const maxItems = parseInt(zone.dataset.maxItems) || 1;
543
+ const currentItems = zoneContent.querySelectorAll('.drag-item.dropped');
544
+
545
+ if (currentItems.length >= maxItems) {
546
+ // Zone is full - don't allow drop
547
+ logger.warn(`Zone "${zoneId}" is full (max: ${maxItems})`);
548
+ return;
549
+ }
550
+
551
+ // If the item is already in a zone, remove it from that zone first
552
+ if (item.classList.contains('dropped')) {
553
+ const currentZone = item.closest('.drop-zone');
554
+ if (currentZone) {
555
+ const _oldZoneId = currentZone.dataset.zoneId;
556
+ delete state.placements[itemId];
557
+ }
558
+ item.remove();
559
+
560
+ // Show the original item in the items area temporarily
561
+ const originalItem = container.querySelector(`.drag-items .drag-item[data-item-id="${escapeCssSelector(itemId)}"]`);
562
+ if (originalItem) {
563
+ originalItem.style.display = '';
564
+ }
565
+ }
566
+
567
+ // Add new item to zone (don't clear existing items)
568
+ const clonedItem = item.cloneNode(true);
569
+ clonedItem.classList.add('dropped');
570
+ clonedItem.classList.remove('keyboard-selected');
571
+ clonedItem.draggable = true;
572
+
573
+ // Add remove button if not already present
574
+ if (!clonedItem.querySelector('.remove-item')) {
575
+ const removeBtn = document.createElement('button');
576
+ removeBtn.type = 'button';
577
+ removeBtn.className = 'remove-item';
578
+ removeBtn.dataset.action = 'remove-item';
579
+ removeBtn.dataset.itemId = itemId;
580
+ removeBtn.setAttribute('aria-label', `Remove ${item.textContent || item.innerText}`);
581
+ removeBtn.setAttribute('title', 'Remove this item');
582
+ removeBtn.textContent = '×';
583
+ clonedItem.appendChild(removeBtn);
584
+ }
585
+
586
+ zoneContent.appendChild(clonedItem);
587
+
588
+ // Setup drag listeners for the cloned item
589
+ setupDragListenersForItem(clonedItem, container, state);
590
+
591
+ // Update state
592
+ state.placements[itemId] = zoneId;
593
+ item.style.display = 'none';
594
+ state.selectedForDrop = null;
595
+ state.draggedElement = null;
596
+ }
597
+
598
+ /**
599
+ * Setup drag event listeners for an item (used for items in zones)
600
+ */
601
+ function setupDragListenersForItem(item, container, state) {
602
+ item.addEventListener('dragstart', (e) => {
603
+ // Prevent drag if clicking on remove button
604
+ if (e.target.classList.contains('remove-item') || e.target.closest('.remove-item')) {
605
+ e.preventDefault();
606
+ return;
607
+ }
608
+ state.draggedElement = e.currentTarget;
609
+ e.currentTarget.setAttribute('aria-grabbed', 'true');
610
+ e.dataTransfer.effectAllowed = 'move';
611
+ e.dataTransfer.setData('text/plain', e.currentTarget.dataset.itemId);
612
+ });
613
+
614
+ item.addEventListener('dragend', (e) => {
615
+ e.target.setAttribute('aria-grabbed', 'false');
616
+ });
617
+
618
+ item.addEventListener('keydown', (e) => {
619
+ if (e.key === 'Enter' || e.key === ' ') {
620
+ e.preventDefault();
621
+ container.querySelectorAll('.drag-item').forEach(i => i.classList.remove('keyboard-selected'));
622
+ state.selectedForDrop = e.currentTarget;
623
+ e.currentTarget.classList.add('keyboard-selected');
624
+ }
625
+ });
626
+
627
+ // Touch support for items in zones
628
+ setupTouchDragForItem(item, container, state);
629
+ }
630
+
631
+ /**
632
+ * Sets up touch drag support for a drag item
633
+ */
634
+ function setupTouchDragForItem(item, container, state) {
635
+ let touchTimeout = null;
636
+ let hasMoved = false;
637
+
638
+ item.addEventListener('touchstart', (e) => {
639
+ // Prevent touch drag if touching remove button
640
+ if (e.target.classList.contains('remove-item') || e.target.closest('.remove-item')) {
641
+ return;
642
+ }
643
+
644
+ const touch = e.touches[0];
645
+ state.touchStartX = touch.clientX;
646
+ state.touchStartY = touch.clientY;
647
+ hasMoved = false;
648
+
649
+ // Store the original item
650
+ state.touchDragElement = item;
651
+
652
+ // Calculate offset from touch point to item top-left
653
+ const rect = item.getBoundingClientRect();
654
+ state.touchOffsetX = touch.clientX - rect.left;
655
+ state.touchOffsetY = touch.clientY - rect.top;
656
+
657
+ // Start drag after a short delay to distinguish from scroll
658
+ touchTimeout = setTimeout(() => {
659
+ if (!hasMoved) {
660
+ startTouchDrag(item, container, state, touch);
661
+ }
662
+ }, 150);
663
+ }, { passive: false });
664
+
665
+ item.addEventListener('touchmove', (e) => {
666
+ if (!state.touchDragElement) return;
667
+
668
+ const touch = e.touches[0];
669
+ const moveX = Math.abs(touch.clientX - state.touchStartX);
670
+ const moveY = Math.abs(touch.clientY - state.touchStartY);
671
+
672
+ // If moved more than threshold, consider it a drag
673
+ if (moveX > 10 || moveY > 10) {
674
+ hasMoved = true;
675
+ if (touchTimeout) {
676
+ clearTimeout(touchTimeout);
677
+ touchTimeout = null;
678
+ }
679
+
680
+ // Start drag if not already started
681
+ if (!state.touchClone) {
682
+ startTouchDrag(item, container, state, touch);
683
+ }
684
+ }
685
+
686
+ // Move the clone if dragging
687
+ if (state.touchClone) {
688
+ e.preventDefault();
689
+ moveTouchClone(state, touch);
690
+ updateTouchDropTargetHighlight(container, touch);
691
+ }
692
+ }, { passive: false });
693
+
694
+ item.addEventListener('touchend', (e) => {
695
+ if (touchTimeout) {
696
+ clearTimeout(touchTimeout);
697
+ touchTimeout = null;
698
+ }
699
+
700
+ if (state.touchClone) {
701
+ const touch = e.changedTouches[0];
702
+ completeTouchDrop(container, state, touch);
703
+ }
704
+
705
+ // Reset touch state
706
+ state.touchDragElement = null;
707
+ hasMoved = false;
708
+ });
709
+
710
+ item.addEventListener('touchcancel', () => {
711
+ if (touchTimeout) {
712
+ clearTimeout(touchTimeout);
713
+ touchTimeout = null;
714
+ }
715
+ cancelTouchDrag(container, state);
716
+ });
717
+ }
718
+
719
+ /**
720
+ * Starts the touch drag by creating a visual clone
721
+ */
722
+ function startTouchDrag(item, container, state, touch) {
723
+ // Prevent default to stop scrolling
724
+ item.setAttribute('aria-grabbed', 'true');
725
+ item.classList.add('touch-dragging');
726
+
727
+ // Create a clone for visual feedback
728
+ const clone = item.cloneNode(true);
729
+ clone.classList.add('touch-drag-clone');
730
+ clone.style.position = 'fixed';
731
+ clone.style.zIndex = '10000';
732
+ clone.style.pointerEvents = 'none';
733
+ clone.style.width = `${item.offsetWidth}px`;
734
+ clone.style.opacity = '0.9';
735
+ clone.style.transform = 'scale(1.05)';
736
+ clone.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.3)';
737
+
738
+ // Position clone at touch point
739
+ clone.style.left = `${touch.clientX - state.touchOffsetX}px`;
740
+ clone.style.top = `${touch.clientY - state.touchOffsetY}px`;
741
+
742
+ document.body.appendChild(clone);
743
+ state.touchClone = clone;
744
+
745
+ // Dim the original item
746
+ item.style.opacity = '0.4';
747
+ }
748
+
749
+ /**
750
+ * Moves the touch clone to follow the finger
751
+ */
752
+ function moveTouchClone(state, touch) {
753
+ if (state.touchClone) {
754
+ state.touchClone.style.left = `${touch.clientX - state.touchOffsetX}px`;
755
+ state.touchClone.style.top = `${touch.clientY - state.touchOffsetY}px`;
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Updates drop target highlight during touch drag
761
+ */
762
+ function updateTouchDropTargetHighlight(container, touch) {
763
+ // Remove existing highlights
764
+ container.querySelectorAll('.drop-zone.drag-over, .drag-items.drag-over').forEach(el => {
765
+ el.classList.remove('drag-over', 'zone-full');
766
+ });
767
+
768
+ // Find element under touch point
769
+ const elementUnder = document.elementFromPoint(touch.clientX, touch.clientY);
770
+ if (!elementUnder) return;
771
+
772
+ // Check if over a drop zone
773
+ const dropZone = elementUnder.closest('.drop-zone');
774
+ if (dropZone && container.contains(dropZone)) {
775
+ const zoneContent = dropZone.querySelector('.zone-content');
776
+ const maxItems = parseInt(dropZone.dataset.maxItems) || 1;
777
+ const currentItems = zoneContent ? zoneContent.querySelectorAll('.drag-item.dropped').length : 0;
778
+ const isFull = currentItems >= maxItems;
779
+
780
+ dropZone.classList.add('drag-over');
781
+ if (isFull) {
782
+ dropZone.classList.add('zone-full');
783
+ }
784
+ return;
785
+ }
786
+
787
+ // Check if over the drag items area (for returning items)
788
+ const dragItemsArea = elementUnder.closest('.drag-items');
789
+ if (dragItemsArea && container.contains(dragItemsArea)) {
790
+ dragItemsArea.classList.add('drag-over');
791
+ }
792
+ }
793
+
794
+ /**
795
+ * Completes the touch drop operation
796
+ */
797
+ function completeTouchDrop(container, state, touch) {
798
+ // Remove highlights
799
+ container.querySelectorAll('.drop-zone.drag-over, .drag-items.drag-over').forEach(el => {
800
+ el.classList.remove('drag-over', 'zone-full');
801
+ });
802
+
803
+ // Remove clone
804
+ if (state.touchClone) {
805
+ state.touchClone.remove();
806
+ state.touchClone = null;
807
+ }
808
+
809
+ // Reset original item appearance
810
+ if (state.touchDragElement) {
811
+ state.touchDragElement.style.opacity = '';
812
+ state.touchDragElement.setAttribute('aria-grabbed', 'false');
813
+ state.touchDragElement.classList.remove('touch-dragging');
814
+ }
815
+
816
+ // Find element under touch point
817
+ const elementUnder = document.elementFromPoint(touch.clientX, touch.clientY);
818
+ if (!elementUnder || !state.touchDragElement) return;
819
+
820
+ // Check if dropped on a drop zone
821
+ const dropZone = elementUnder.closest('.drop-zone');
822
+ if (dropZone && container.contains(dropZone)) {
823
+ performDrop(container, state, state.touchDragElement, dropZone);
824
+ return;
825
+ }
826
+
827
+ // Check if dropped on drag items area (returning an item)
828
+ const dragItemsArea = elementUnder.closest('.drag-items');
829
+ if (dragItemsArea && container.contains(dragItemsArea)) {
830
+ if (state.touchDragElement.classList.contains('dropped')) {
831
+ const itemId = state.touchDragElement.dataset.itemId;
832
+ removeItemFromZone(container, itemId);
833
+ }
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Cancels a touch drag operation
839
+ */
840
+ function cancelTouchDrag(container, state) {
841
+ // Remove highlights
842
+ container.querySelectorAll('.drop-zone.drag-over, .drag-items.drag-over').forEach(el => {
843
+ el.classList.remove('drag-over', 'zone-full');
844
+ });
845
+
846
+ // Remove clone
847
+ if (state.touchClone) {
848
+ state.touchClone.remove();
849
+ state.touchClone = null;
850
+ }
851
+
852
+ // Reset original item appearance
853
+ if (state.touchDragElement) {
854
+ state.touchDragElement.style.opacity = '';
855
+ state.touchDragElement.setAttribute('aria-grabbed', 'false');
856
+ state.touchDragElement.classList.remove('touch-dragging');
857
+ }
858
+
859
+ state.touchDragElement = null;
860
+ }
861
+
862
+ /**
863
+ * Removes an item from a drop zone and returns it to the items area
864
+ */
865
+ function removeItemFromZone(container, itemId) {
866
+ const state = container._dragDropState;
867
+ if (!state) return;
868
+
869
+ // Find the dropped item in a zone
870
+ const droppedItem = container.querySelector(`.drop-zone .drag-item[data-item-id="${escapeCssSelector(itemId)}"]`);
871
+ if (droppedItem) {
872
+ droppedItem.remove();
873
+ }
874
+
875
+ // Show the original item in the items area
876
+ const originalItem = container.querySelector(`.drag-items .drag-item[data-item-id="${escapeCssSelector(itemId)}"]`);
877
+ if (originalItem) {
878
+ originalItem.style.display = '';
879
+ }
880
+
881
+ // Update state
882
+ delete state.placements[itemId];
883
+ }
884
+