{
  "generatedFrom": "modal.contract.json",
  "component": "Modal",
  "slug": "modal",
  "group": "Feedback",
  "tier": "composite-antd",
  "summary": "A focused, blocking dialog in two types — a Confirmation (yes/no decision) and an Action (a task surface) — rendered through the shared Modal.",
  "install": {
    "package": "@ahaslides-product/design",
    "registry": "https://npm.pkg.github.com",
    "scope": "@ahaslides-product",
    "auth": "Published to GitHub Packages — needs a GitHub token with read:packages. Configure the @ahaslides-product scope in .npmrc before installing.",
    "npmrc": "@ahaslides-product:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}   # a GitHub token with read:packages",
    "command": "npm i @ahaslides-product/design",
    "tokenLayer": "import '@ahaslides-product/design/tokens.css';",
    "import": "import '@ahaslides-product/design/modal-theme';",
    "element": null,
    "registers": null
  },
  "feeds": {
    "doc": "https://ahaslides-product.github.io/ahaslides-design/modal/index.html",
    "md": "https://ahaslides-product.github.io/ahaslides-design/modal/modal.md",
    "agentJson": "https://ahaslides-product.github.io/ahaslides-design/modal.agent.json",
    "llms": "https://ahaslides-product.github.io/ahaslides-design/modal.llms.txt",
    "index": "https://ahaslides-product.github.io/ahaslides-design/llms.txt"
  },
  "recommendedSnippet": "html",
  "vibeCode": "Emit the HTML snippet by default — a CDN-React runnable page (React + antd loaded from a CDN, no build step) — React/Vue use the same shared-themed DataTable via your bundler. Paste into an .html file and it renders with no build step.",
  "frameworks": {
    "react": {
      "package": "antd",
      "major": 6
    },
    "vue": {
      "package": "ant-design-vue",
      "major": 4
    },
    "html": {
      "via": "cdn-react",
      "runnable": "paste-and-run — React + antd loaded from a CDN, no build step"
    }
  },
  "props": [
    {
      "name": "open",
      "type": "boolean",
      "default": "false",
      "desc": "Controls visibility"
    },
    {
      "name": "title",
      "type": "string",
      "default": "—",
      "desc": "Dialog heading"
    },
    {
      "name": "onOk",
      "type": "() => void",
      "default": "—",
      "desc": "Confirm handler"
    },
    {
      "name": "onCancel",
      "type": "() => void",
      "default": "—",
      "desc": "Dismiss handler"
    },
    {
      "name": "confirmLoading",
      "type": "boolean",
      "default": "false",
      "desc": "Spinner on the confirm button"
    },
    {
      "name": "width",
      "type": "string",
      "default": "modalWidth('simple')",
      "desc": "Dialog width — pass modalWidth(size): min(target px, calc(100vw − 32px)) so it's the px width on desktop and near-full-width on mobile. Targets: simple 504 / complexity 720 / rich 1280"
    },
    {
      "name": "styles",
      "type": "object",
      "default": "modalStyles('simple')",
      "desc": "Height cap + body scroll for the size — modalStyles(size)"
    },
    {
      "name": "footer",
      "type": "ReactNode",
      "default": "—",
      "desc": "DS footer: Learn-more link (left, a --aha-text-link anchor — external-link glyph only when it leaves AhaSlides) + Cancel + Apply (right)"
    },
    {
      "name": "centered",
      "type": "boolean",
      "default": "false",
      "desc": "Vertically centre the dialog in the viewport"
    },
    {
      "name": "closable",
      "type": "boolean",
      "default": "true",
      "desc": "Show the top-right close (X)"
    },
    {
      "name": "mask",
      "type": "boolean",
      "default": "true",
      "desc": "Render the dimming overlay behind the dialog"
    }
  ],
  "tokens": [
    "colorPrimary",
    "borderRadius",
    "Modal.contentBg",
    "Modal.headerBg",
    "Modal.titleColor",
    "Modal.titleFontSize"
  ],
  "spec": [
    {
      "label": "Content",
      "value": "white surface · radius 8"
    },
    {
      "label": "Title",
      "value": "ink #1A1A1A · 18 · SemiBold"
    },
    {
      "label": "Footer",
      "value": "brand primary confirm + secondary cancel"
    },
    {
      "label": "Overlay",
      "value": "always-on mask (ink rgba(26,26,46,.7)) · portals to body · locks page scroll · click mask / Esc / ✕ to close (destructive: mask not closable)"
    },
    {
      "label": "Motion",
      "value": "antd's built-in zoom enter/leave (kept)"
    },
    {
      "label": "Types",
      "value": "Confirmation (status icon + copy) · Action (task surface)"
    },
    {
      "label": "Confirmation",
      "value": "contexts: default · confirm · warning · info · danger (status icon left of title)"
    },
    {
      "label": "Action sizes",
      "value": "simple 504 · complexity 720 · rich 1280 (px width) × height 75/80/90vh · divider on/off"
    },
    {
      "label": "Viewport cap",
      "value": "width={modalWidth(size)} = min(px, calc(100vw − 32px)) — px on desktop, near-full-width on mobile; styles={modalStyles(size)} caps height, body scrolls past it"
    }
  ],
  "opinion": {
    "whenToUse": [
      {
        "what": "Modal",
        "when": "the user must confirm or complete a focused task before continuing"
      },
      {
        "what": "Drawer",
        "when": "a longer edit form or detail panel that keeps page context"
      },
      {
        "what": "Popconfirm",
        "when": "a lightweight yes/no on a single control"
      }
    ],
    "note": "Two types. A CONFIRMATION modal is a focused yes/no — one of five contexts (default · confirm · warning · info · danger) puts a status icon left of the title, with a short line of copy, a Learn-more link, and Cancel + Apply; it's always `simple` size. An ACTION modal is a task surface (form, picker, editor) at one of three sizes — simple / complexity / rich — with an optional header/footer divider. Both are controlled `<Modal>`s, never the static Modal.confirm(). Every modal opens as a real overlay: it portals to `<body>` with an always-on mask, locks page scroll while open, and closes on a mask click / Esc / the ✕ — EXCEPT a destructive confirmation, which overrides `mask={{ closable: false }}` so a stray backdrop click can't trigger the action (Esc + ✕ only). Name the primary button the action ('Delete team', not 'OK'). A modal must never grow bigger than the screen in either axis: set `width={modalWidth(size)}` (resolves to `min(<target px>, calc(100vw − 32px))` — the px width on desktop, near-full-width on mobile with a 16px gutter; antd centres the dialog by its width, so the cap lives on the prop, not inner styles) and `styles={modalStyles(size)}` (height `auto` up to the cap, then the body scrolls while title + footer stay pinned). Anything bigger than `rich` belongs in its own page.",
    "dsv3": "COVERED — antd v6 Modal themed once by modalTheme. TWO types: Confirmation (5 contexts, status icon + copy + Learn-more + Cancel/Apply, `simple` size) and Action (sizes simple 504 / complexity 720 / rich 1280 px width × 75/80/90vh height, divider on/off). Width via width={modalWidth(size)} = min(px, calc(100vw − 32px)) — px on desktop, near-full-width on mobile; height via styles={modalStyles(size)}. A modal never grows bigger than the screen — the body scrolls, the title + footer stay pinned. Both types are controlled <Modal>s — never the static Modal.confirm(), which the DS forbids (renders outside the themed tree). Verified against the DS V3 Figma (fileKey MwjvUjVI0HnfwD9EwPXRAy): ConfirmationModal (Context=Default/Confirm/Warning/Info/Danger) and Action modal (use case=simple/complexity/rich × divider), with the max-width/max-height annotations."
  },
  "surfaces": [
    "editor",
    "dashboard",
    "settings"
  ],
  "snippets": {
    "html": "<!-- Paste-and-run: save as .html and open in a browser. No build step, no bundler.\n     Modal is a COMPOSITE (antd, no framework-free element), so its HTML form is a\n     CDN-React page — React + antd load from a CDN (esm.sh) and mount on open. It\n     consumes the SAME shared modalTheme / modalWidth / modalStyles the React/Vue\n     wrappers use, so it renders the DS V3 look (white content, radius 8, ink title)\n     and stays inside the viewport. Keeps antd's built-in motion.\n     Shows the TWO modal types — a Confirmation (simple) and an Action (complexity) —\n     each TRIGGERED by a button, both controlled <Modal>s (never static Modal.confirm).\n     No JSX here (JSX needs a compiler) — we use React.createElement via the `h` alias. -->\n<div id=\"root\"></div>\n<script type=\"module\">\n  import React from 'https://esm.sh/react@18';\n  import { createRoot } from 'https://esm.sh/react-dom@18/client';\n  import { ConfigProvider, Modal, Button } from 'https://esm.sh/antd@6?deps=react@18,react-dom@18';\n  import { modalTheme, modalStyles, modalWidth } from 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/modal-theme.js';\n  import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/icons.js';   // registers <aha-icon>\n\n  const h = React.createElement;\n  const { useState } = React;\n\n  // External = an absolute URL whose host isn't ahaslides.com — only then show the ↗ glyph + a new tab.\n  const isExternalUrl = (href) =>\n    /^https?:\\/\\//i.test(href) && !/(^|\\.)ahaslides\\.com$/i.test(new URL(href).hostname);\n  const learnMore = 'https://help.example.com/reuse-themes';   // external → shows the glyph\n  const external = isExternalUrl(learnMore);\n\n  // Footer: a \"Learn more\" link (navigation → a DS link, --aha-text-link + shared <aha-icon>, not a\n  // Button) on the left, Cancel + Apply on the right. The glyph shows ONLY when the link leaves AhaSlides.\n  const footer = (danger, close) => h('div',\n    { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },\n    h('a', { href: learnMore, target: external ? '_blank' : undefined, rel: external ? 'noreferrer' : undefined,\n             style: { display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--aha-text-link)', textDecoration: 'none' } },\n      'Learn more ', external && h('aha-icon', { name: 'system-arrow-square-out', size: '14' })),\n    h('span', { style: { display: 'inline-flex', gap: 8 } },\n      h(Button, { onClick: close }, 'Cancel'),\n      h(Button, { type: 'primary', danger }, 'Apply')));\n\n  function App() {\n    const [confirm, setConfirm] = useState(false);\n    const [action, setAction] = useState(false);\n\n    return h(ConfigProvider, { theme: modalTheme },\n      h(Button, { onClick: () => setConfirm(true) }, 'Delete team…'),\n      h(Button, { type: 'primary', onClick: () => setAction(true) }, 'Share course…'),\n\n      // Confirmation modal — always `simple` (504px · 75vh); danger tints Apply.\n      h(Modal, {\n        open: confirm, title: 'Delete this team?',\n        width: modalWidth('simple'), styles: modalStyles('simple'), centered: true,\n        footer: footer(true, () => setConfirm(false)),\n        onCancel: () => setConfirm(false),\n      }, h('p', null, \"This removes the team and everyone's access. This can't be undone.\")),\n\n      // Action modal — a longer task; `complexity` (720px · 80vh), body scrolls when tall.\n      h(Modal, {\n        open: action, title: 'Share course',\n        width: modalWidth('complexity'), styles: modalStyles('complexity'), centered: true,\n        footer: footer(false, () => setAction(false)),\n        onCancel: () => setAction(false),\n      })\n    );\n  }\n  // Same shared theme + helpers the React/Vue wrappers pass to ConfigProvider.\n  createRoot(document.getElementById('root')).render(h(App));\n</script>\n",
    "react": "import { useState } from 'react';\nimport { ConfigProvider, Modal, Button } from 'antd';   // antd v6\nimport { modalTheme, modalStyles, modalWidth } from '@ahaslides-product/design/modal-theme';\nimport '@ahaslides-product/design/icons';   // registers <aha-icon> for the Learn-more glyph\n\n// External = an absolute URL whose host isn't ahaslides.com — only then does a link show the\n// external-link glyph and open in a new tab. A relative / *.ahaslides.com link stays in-product.\nconst isExternalUrl = (href) =>\n  /^https?:\\/\\//i.test(href) && !/(^|\\.)ahaslides\\.com$/i.test(new URL(href).hostname);\n\n// The DS has TWO modal types, both controlled <Modal>s (never the imperative confirm dialog):\n//   • Confirmation — a focused yes/no with an optional status icon + Learn-more link. `simple` size.\n//   • Action       — a task surface (form / picker / editor). Sizes: simple · complexity · rich.\n// A button TRIGGERS each; width={modalWidth(size)} keeps it near-full-width on mobile (min(px,\n// 100vw−32)) and styles={modalStyles(size)} caps the height, the body scrolling past the cap.\nfunction ModalDemo() {\n  const [confirm, setConfirm] = useState(false);\n  const [action, setAction] = useState(false);\n\n  const learnMore = 'https://help.example.com/reuse-themes';   // external → shows the ↗ glyph\n  const external = isExternalUrl(learnMore);\n  const footer = (danger) => (\n    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>\n      {/* \"Learn more\" is navigation → a DS link (--aha-text-link + shared <aha-icon>), not a Button.\n          The external-link glyph shows ONLY when the target leaves AhaSlides. */}\n      <a href={learnMore} target={external ? '_blank' : undefined} rel={external ? 'noreferrer' : undefined}\n         style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--aha-text-link)', textDecoration: 'none' }}>\n        Learn more {external && <aha-icon name=\"system-arrow-square-out\" size=\"14\" />}\n      </a>\n      <span style={{ display: 'inline-flex', gap: 8 }}>\n        <Button onClick={() => { setConfirm(false); setAction(false); }}>Cancel</Button>\n        <Button type=\"primary\" danger={danger}>Apply</Button>\n      </span>\n    </div>\n  );\n\n  return (\n    <ConfigProvider theme={modalTheme}>\n      <Button onClick={() => setConfirm(true)}>Delete team…</Button>\n      <Button type=\"primary\" onClick={() => setAction(true)}>Share course…</Button>\n\n      {/* Confirmation modal — always `simple`; the danger context tints Apply. */}\n      <Modal\n        open={confirm}\n        title=\"Delete this team?\"\n        width={modalWidth('simple')}\n        styles={modalStyles('simple')}\n        centered\n        footer={footer(true)}\n        onCancel={() => setConfirm(false)}\n      >\n        <p>This removes the team and everyone's access. This can't be undone.</p>\n      </Modal>\n\n      {/* Action modal — a longer task; `complexity` (720px · 80vh), body scrolls when tall. */}\n      <Modal\n        open={action}\n        title=\"Share course\"\n        width={modalWidth('complexity')}\n        styles={modalStyles('complexity')}\n        centered\n        footer={footer(false)}\n        onCancel={() => setAction(false)}\n      >\n        {/* …form fields / picker / editor… */}\n      </Modal>\n    </ConfigProvider>\n  );\n}\n// One shared modalTheme → the DS V3 look; keeps antd's built-in zoom motion.\n",
    "vue": "<script setup>\nimport { ref, h } from 'vue';\nimport { ConfigProvider, Modal, Button } from 'ant-design-vue';   // ant-design-vue v4\nimport { modalTheme, modalStyles, modalWidth } from '@ahaslides-product/design/modal-theme';\nimport '@ahaslides-product/design/icons';   // registers <aha-icon> for the Learn-more glyph\n\n// External = an absolute URL whose host isn't ahaslides.com — only then show the ↗ glyph + a new tab.\nconst isExternalUrl = (href) =>\n  /^https?:\\/\\//i.test(href) && !/(^|\\.)ahaslides\\.com$/i.test(new URL(href).hostname);\nconst learnMore = 'https://help.example.com/reuse-themes';   // external → shows the glyph\nconst external = isExternalUrl(learnMore);\n\n// The DS has TWO modal types, both controlled <a-modal>s (never the imperative confirm dialog):\n//   • Confirmation — a focused yes/no with an optional status icon + Learn-more link. `simple` size.\n//   • Action       — a task surface (form / picker / editor). Sizes: simple · complexity · rich.\n// A button TRIGGERS each; :width=\"modalWidth(size)\" keeps it near-full-width on mobile (min(px,\n// 100vw−32)) and :styles=\"modalStyles(size)\" caps the height, the body scrolling past the cap.\nconst confirm = ref(false);\nconst action = ref(false);\n\n// Footer render-fn: a Learn-more link (navigation → DS link, not a Button) on the left,\n// Cancel + Apply on the right.\nconst footer = (danger, close) => h('div',\n  { style: 'display:flex;align-items:center;justify-content:space-between' }, [\n    h('a', { href: learnMore, target: external ? '_blank' : undefined, rel: external ? 'noreferrer' : undefined,\n             style: 'display:inline-flex;align-items:center;gap:6px;color:var(--aha-text-link);text-decoration:none' }, [\n      'Learn more ', external ? h('aha-icon', { name: 'system-arrow-square-out', size: '14' }) : null,\n    ]),\n    h('span', { style: 'display:inline-flex;gap:8px' }, [\n      h(Button, { onClick: close }, () => 'Cancel'),\n      h(Button, { type: 'primary', danger }, () => 'Apply'),\n    ]),\n  ]);\n</script>\n\n<template>\n  <a-config-provider :theme=\"modalTheme\">\n    <a-button @click=\"confirm = true\">Delete team…</a-button>\n    <a-button type=\"primary\" @click=\"action = true\">Share course…</a-button>\n\n    <!-- Confirmation modal — always `simple`; the danger context tints Apply. -->\n    <a-modal\n      v-model:open=\"confirm\"\n      title=\"Delete this team?\"\n      :width=\"modalWidth('simple')\"\n      :styles=\"modalStyles('simple')\"\n      centered\n      :footer=\"footer(true, () => (confirm = false))\"\n    >\n      <p>This removes the team and everyone's access. This can't be undone.</p>\n    </a-modal>\n\n    <!-- Action modal — a longer task; `complexity` (720px · 80vh), body scrolls when tall. -->\n    <a-modal\n      v-model:open=\"action\"\n      title=\"Share course\"\n      :width=\"modalWidth('complexity')\"\n      :styles=\"modalStyles('complexity')\"\n      centered\n      :footer=\"footer(false, () => (action = false))\"\n    >\n      <!-- …form fields / picker / editor… -->\n    </a-modal>\n  </a-config-provider>\n</template>\n<!-- The SAME modalTheme + modalWidth/modalStyles as React → one DS V3 look across both vendor libraries. -->\n",
    "theme": "// @ahaslides-product/design/modal-theme — declared ONCE, consumed by both tiers.\nimport { modalTheme, modalStyles, modalWidth } from '@ahaslides-product/design/modal-theme';\n\nexport const modalTheme = {\n  token: {\n    colorPrimary: '#6A1EBB',\n    borderRadius: 8,\n    colorText: '#1A1A1A',\n    colorBgElevated: '#FFFFFF',\n    fontFamily: 'var(--aha-font-product, \"Plus Jakarta Sans\", sans-serif)',\n  },\n  components: {\n    Modal: {\n      contentBg: '#FFFFFF',\n      headerBg: '#FFFFFF',\n      titleColor: '#1A1A1A',\n      titleFontSize: 18,\n      borderRadiusLG: 8,\n    },\n  },\n};\n\n// DS V3 size policy — a modal never grows bigger than the screen and stays usable on mobile.\n// Three size tiers (Action-modal use cases; a Confirmation modal uses `simple`), each a target\n// px width. WIDTH via the `width` prop: modalWidth(size) → min(<target px>, calc(100vw − 32px)),\n// so it's the px width on desktop and near-full-width on a phone (a fixed vw would collapse to\n// ~135px). HEIGHT via styles={modalStyles(size)} — the body scrolls, title + footer stay pinned.\nexport const modalMaxHeight = { simple: '75vh', complexity: '80vh', rich: '90vh' };\nconst MODAL_TARGET_W = { simple: 504, complexity: 720, rich: 1280 };\nconst MODAL_GUTTER = 32;   // 16px each side kept on small screens\n// Per-tier vw on a 1440 design — reference only; modalWidth() does NOT use it (not mobile-safe).\nexport const modalMaxWidth  = { simple: '35vw', complexity: '50vw', rich: '90vw' };\nexport const modalWidth = (size = 'simple') => `min(${MODAL_TARGET_W[size]}px, calc(100vw - ${MODAL_GUTTER}px))`;\nexport const modalStyles = (size = 'simple') => ({\n  // `container` = the antd v6 dialog box (.ant-modal-container), NOT `content` (dead key in v6).\n  container: { display: 'flex', flexDirection: 'column', maxHeight: modalMaxHeight[size] },\n  body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },\n});\n"
  }
}
