@motir/design-system 0.19.0 → 0.20.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/dist/components/ui/Modal.d.ts +23 -1
- package/dist/components/ui/Modal.js +17 -3
- package/dist/components/ui/Modal.js.map +1 -1
- package/package.json +1 -1
- package/theme.css +14 -0
|
@@ -82,8 +82,30 @@ interface ModalProps extends VariantProps<typeof contentVariants> {
|
|
|
82
82
|
* `document.body`.
|
|
83
83
|
*/
|
|
84
84
|
container?: Element | null;
|
|
85
|
+
/**
|
|
86
|
+
* More element ids that DESCRIBE the dialog, added to its `aria-describedby`
|
|
87
|
+
* after the `description`'s own id (MOTIR-10544; design MOTIR-10543's
|
|
88
|
+
* can't-plan alert). For a dialog whose meaning lives in its BODY as well as
|
|
89
|
+
* its one-line description — a list of reasons — so assistive tech reads the
|
|
90
|
+
* body with the title rather than only when the reader tabs into it. A
|
|
91
|
+
* space-separated id list, as `aria-describedby` takes it.
|
|
92
|
+
*/
|
|
93
|
+
describedBy?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Where focus goes when the dialog opens — Radix's `onOpenAutoFocus`. Call
|
|
96
|
+
* `event.preventDefault()` and focus an element yourself to land on a control
|
|
97
|
+
* other than the first focusable one (an alert lands on its safe action).
|
|
98
|
+
*/
|
|
99
|
+
onOpenAutoFocus?: (event: Event) => void;
|
|
100
|
+
/**
|
|
101
|
+
* Where focus goes when the dialog closes — Radix's `onCloseAutoFocus`. A
|
|
102
|
+
* dialog opened WITHOUT a `Modal.Trigger` has nothing for Radix to return
|
|
103
|
+
* focus to, so a consumer that opened it from a control calls
|
|
104
|
+
* `event.preventDefault()` and focuses that control here.
|
|
105
|
+
*/
|
|
106
|
+
onCloseAutoFocus?: (event: Event) => void;
|
|
85
107
|
}
|
|
86
|
-
declare function ModalRoot({ open, onOpenChange, title, description, size, hideClose, srTitle, className, overlayClassName, role, children, closeLabel, dialogLabel, container, }: ModalProps): react_jsx_runtime.JSX.Element;
|
|
108
|
+
declare function ModalRoot({ open, onOpenChange, title, description, size, hideClose, srTitle, className, overlayClassName, role, children, closeLabel, dialogLabel, container, describedBy, onOpenAutoFocus, onCloseAutoFocus, }: ModalProps): react_jsx_runtime.JSX.Element;
|
|
87
109
|
declare const Modal: typeof ModalRoot & {
|
|
88
110
|
Body: react.ForwardRefExoticComponent<react.HTMLAttributes<HTMLDivElement> & react.RefAttributes<HTMLDivElement>>;
|
|
89
111
|
Footer: react.ForwardRefExoticComponent<react.HTMLAttributes<HTMLDivElement> & react.RefAttributes<HTMLDivElement>>;
|
|
@@ -92,11 +92,16 @@ function ModalRoot({
|
|
|
92
92
|
children,
|
|
93
93
|
closeLabel = "Close",
|
|
94
94
|
dialogLabel = "Dialog",
|
|
95
|
-
container
|
|
95
|
+
container,
|
|
96
|
+
describedBy,
|
|
97
|
+
onOpenAutoFocus,
|
|
98
|
+
onCloseAutoFocus
|
|
96
99
|
}) {
|
|
97
100
|
const contentRef = useRef(null);
|
|
98
101
|
const fullscreenContainer = usePortalContainer(contentRef);
|
|
99
102
|
const portalContainer = container === void 0 ? fullscreenContainer ?? void 0 : container;
|
|
103
|
+
const descriptionId = describedBy ? `${describedBy}-description` : void 0;
|
|
104
|
+
const describedByIds = describedBy ? [description ? descriptionId : null, describedBy].filter(Boolean).join(" ") : null;
|
|
100
105
|
return /* @__PURE__ */ jsx(Dialog.Root, { open, onOpenChange, children: /* @__PURE__ */ jsxs(Dialog.Portal, { container: portalContainer, children: [
|
|
101
106
|
/* @__PURE__ */ jsx(
|
|
102
107
|
Dialog.Overlay,
|
|
@@ -112,11 +117,20 @@ function ModalRoot({
|
|
|
112
117
|
className: cn(contentVariants({ size }), className),
|
|
113
118
|
"data-surface": "modal",
|
|
114
119
|
...role ? { role } : {},
|
|
115
|
-
...description ? {} : { "aria-describedby": void 0 },
|
|
120
|
+
...describedByIds ? { "aria-describedby": describedByIds } : description ? {} : { "aria-describedby": void 0 },
|
|
121
|
+
...onOpenAutoFocus ? { onOpenAutoFocus } : {},
|
|
122
|
+
...onCloseAutoFocus ? { onCloseAutoFocus } : {},
|
|
116
123
|
children: [
|
|
117
124
|
title || description ? /* @__PURE__ */ jsxs("div", { className: "mb-(--spacing-md) shrink-0", children: [
|
|
118
125
|
title ? /* @__PURE__ */ jsx(Dialog.Title, { className: "font-serif text-xl font-semibold text-(--el-text)", children: title }) : null,
|
|
119
|
-
description ? /* @__PURE__ */ jsx(
|
|
126
|
+
description ? /* @__PURE__ */ jsx(
|
|
127
|
+
Dialog.Description,
|
|
128
|
+
{
|
|
129
|
+
...describedByIds ? { id: descriptionId } : {},
|
|
130
|
+
className: "text-(--el-text-subtitle) mt-1 font-sans text-sm",
|
|
131
|
+
children: description
|
|
132
|
+
}
|
|
133
|
+
) : null
|
|
120
134
|
] }) : (
|
|
121
135
|
// Radix requires Title for a11y; provide a visually-hidden one if
|
|
122
136
|
// missing. `srTitle` lets a consumer with its own visible heading
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/utils/cn.ts","../../../src/utils/fullscreen.ts","../../../src/components/ui/Modal.tsx"],"names":["ModalFooter","ModalBody"],"mappings":"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","file":"Modal.js","sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\n/**\n * Merge Tailwind class names without conflicts.\n *\n * `clsx` handles conditional inclusion (booleans, undefineds, arrays);\n * `twMerge` resolves conflicting Tailwind classes (later wins, e.g.\n * `px-2 px-4` → `px-4`). Together they are the de facto standard for\n * composing Tailwind classes in React components.\n *\n * @example\n * cn('px-2 py-1', isActive && 'bg-primary', className)\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","'use client';\n\nimport { useState, useSyncExternalStore, type RefObject } from 'react';\n\n/**\n * The element currently in NATIVE full screen (`document.fullscreenElement`), or\n * `null` — re-read on every `fullscreenchange`.\n *\n * While an element is in native full screen the browser paints ONLY that\n * element's subtree, in the top layer. Anything portalled to `document.body` —\n * a dialog, a popover, a picker's menu — is not shown at all, whatever its\n * z-index (MOTIR-7658: the roadmap canvas's quick view opened invisibly and\n * held focus and scroll lock until Esc).\n *\n * A portalling primitive does not use this directly: it uses\n * `usePortalContainer`, which also refuses to follow full screen into the\n * primitive's own subtree.\n *\n * `null` on the server and in a document with no Fullscreen API.\n */\nexport function useFullscreenElement(): Element | null {\n return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n}\n\nfunction subscribe(onChange: () => void): () => void {\n document.addEventListener('fullscreenchange', onChange);\n return () => document.removeEventListener('fullscreenchange', onChange);\n}\n\nfunction getSnapshot(): Element | null {\n return document.fullscreenElement ?? null;\n}\n\nfunction getServerSnapshot(): null {\n return null;\n}\n\n/**\n * The element a portalling surface mounts into: the element in native full\n * screen while there is one (MOTIR-7658), else `null` — which the caller turns\n * into `document.body`.\n *\n * `own` is the surface's portalled root (a dialog's content, a menu). When the\n * element that goes full screen is INSIDE it — the native full-screen button of\n * a `<video>` in a dialog — the container stays where it was (MOTIR-8560).\n * Following it would re-portal the surface into its own descendant: React\n * unmounts the subtree to move it, the full-screen element leaves the document,\n * the browser exits full screen, and the surface snaps back to a fresh copy.\n *\n * `null` on the server and in a document with no Fullscreen API.\n */\nexport function usePortalContainer(own: RefObject<Element | null>): Element | null {\n const [store] = useState(() => createPortalContainerStore(own));\n return useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot);\n}\n\nfunction createPortalContainerStore(own: RefObject<Element | null>) {\n let container: Element | null =\n typeof document === 'undefined' ? null : (document.fullscreenElement ?? null);\n return {\n subscribe(onChange: () => void): () => void {\n const sync = () => {\n const next = document.fullscreenElement ?? null;\n const root = own.current;\n // The surface's own descendant went full screen: stay put.\n if (next !== null && root !== null && root.contains(next)) return;\n if (next === container) return;\n container = next;\n onChange();\n };\n // A change between creating the store and subscribing to it.\n sync();\n document.addEventListener('fullscreenchange', sync);\n return () => document.removeEventListener('fullscreenchange', sync);\n },\n getSnapshot: (): Element | null => container,\n };\n}\n","'use client';\n\nimport { forwardRef, useRef, type ReactNode } from 'react';\nimport * as Dialog from '@radix-ui/react-dialog';\nimport { X } from 'lucide-react';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '../../utils/cn';\nimport { usePortalContainer } from '../../utils/fullscreen';\n\n/**\n * Modal — accessible dialog wrapping @radix-ui/react-dialog.\n *\n * Radix handles focus trap, ESC-to-close, click-outside-to-close, and\n * focus-return-on-close out of the box. We style on top.\n *\n * Open/close is controlled by the consumer via `open` + `onOpenChange`.\n *\n * For a scrollable body with a pinned footer, wrap the fields in `Modal.Body`\n * (it owns the `flex-1 overflow-y-auto` scroll recipe and keeps focus rings\n * from clipping against the scroll edge — see `ModalBody`).\n *\n * @example\n * const [open, setOpen] = useState(false);\n * <Modal open={open} onOpenChange={setOpen} title=\"Confirm\" size=\"md\">\n * <Modal.Body className=\"gap-4\">\n * <Input label=\"Name\" autoFocus />\n * </Modal.Body>\n * <Modal.Footer>\n * <Button variant=\"ghost\" onClick={() => setOpen(false)}>Cancel</Button>\n * <Button variant=\"primary\" onClick={confirm}>Confirm</Button>\n * </Modal.Footer>\n * </Modal>\n */\nconst contentVariants = cva(\n cn(\n 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',\n 'w-[90vw] rounded-(--radius-modal) bg-(--el-page-bg)',\n 'shadow-(--shadow-modal) border border-(--el-border)',\n 'p-(--spacing-card-padding)',\n 'focus:outline-none',\n // Never exceed the viewport: cap height and lay out as a column so a\n // consumer can wrap its fields in `Modal.Body` (which owns the\n // `flex-1 overflow-y-auto` scroll recipe) and pin a `Modal.Footer`,\n // instead of the dialog growing off-screen (e.g. the create modal's\n // expandable Explanation pushing the Create button out of view).\n 'flex max-h-[90vh] flex-col overflow-hidden',\n ),\n {\n variants: {\n // Literal widths, NOT the max-w-sm/md/lg utilities: the design\n // system's @theme defines --spacing-sm/md/lg (12/16/20px), and\n // Tailwind v4 resolves `max-w-{key}` against the --spacing-* scale\n // when that key exists — so `max-w-md` would collapse the modal to\n // 16px wide. Pinning the rem values (Tailwind's stock sm/md/lg) keeps\n // the design-system token set untouched and the dialog readable.\n size: {\n sm: 'max-w-[24rem]',\n md: 'max-w-[28rem]',\n lg: 'max-w-[32rem]',\n // The large peek surface (Subtask 2.5.19's quick view) — a generous\n // dialog that takes a big part of the screen; pairs with a height +\n // p-0 className on the consumer side. Stock Tailwind 4xl rem value, so\n // the design-system --spacing-* scale stays untouched (see the comment\n // above on why we pin rems instead of max-w-{key}).\n xl: 'max-w-[58rem]',\n // The whole viewport (Subtask 5.2.6's attachment preview lightbox —\n // the 2.5.19 growth pattern one step further). Like `xl`, the variant\n // only lifts the size caps; the consumer pairs it with bg/border/\n // radius/padding overrides to drop the panel chrome it doesn't want.\n full: 'h-dvh max-h-dvh w-screen max-w-none',\n },\n },\n defaultVariants: { size: 'md' },\n },\n);\n\nexport interface ModalProps extends VariantProps<typeof contentVariants> {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title?: string;\n description?: string;\n children?: ReactNode;\n /** Hide the default close (×) button in the corner. */\n hideClose?: boolean;\n /**\n * Accessible name for a dialog that renders its OWN visible heading (so no\n * `title` is passed). Radix requires a Dialog.Title; without `title`/`srTitle`\n * we fall back to the generic \"Dialog\". Supply this to label the dialog with\n * something meaningful (e.g. the previewed issue's key) while keeping the\n * heading inside `children` (Subtask 2.5.19's quick-view peek).\n */\n srTitle?: string;\n className?: string;\n /**\n * Extra classes merged onto the backdrop — e.g. the attachment preview\n * lightbox (Subtask 5.2.6) deepens the default `--el-overlay-scrim` to\n * `bg-black/80` per `design/work-items/attachments.mock.html` panel 6.\n */\n overlayClassName?: string;\n /**\n * The dialog's ARIA role. Defaults to Radix's `dialog`; a DESTRUCTIVE confirm\n * passes `alertdialog` (Subtask 2.8.4's delete confirm) so assistive tech\n * treats it as an alert that interrupts and announces its description.\n */\n role?: 'dialog' | 'alertdialog';\n /**\n * Accessible label for the corner close (×) button. Defaults to `'Close'`.\n * i18n is the consuming app's concern — the design-system package is\n * framework-agnostic (no `next-intl` dependency), so a localized app passes\n * the translated string here (motir-core wires it from its `common.close`).\n */\n closeLabel?: string;\n /**\n * Fallback accessible name when neither `title` nor `srTitle` is given.\n * Defaults to `'Dialog'`. See `closeLabel` for the i18n rationale.\n */\n dialogLabel?: string;\n /**\n * The element the dialog portals into. Defaults to the element in NATIVE full\n * screen while there is one (`document.fullscreenElement` — the browser paints\n * nothing outside it, so a body-portalled dialog would open invisibly,\n * MOTIR-7658), else `document.body` — unless that element is inside the\n * dialog itself, where the dialog stays put (MOTIR-8560). Pass `null` to force\n * `document.body`.\n */\n container?: Element | null;\n}\n\nfunction ModalRoot({\n open,\n onOpenChange,\n title,\n description,\n size,\n hideClose,\n srTitle,\n className,\n overlayClassName,\n role,\n children,\n closeLabel = 'Close',\n dialogLabel = 'Dialog',\n container,\n}: ModalProps) {\n // The dialog's own content: a full-screen element inside it (a `<video>`'s\n // native full-screen button) must not pull the dialog into itself (MOTIR-8560).\n const contentRef = useRef<HTMLDivElement>(null);\n const fullscreenContainer = usePortalContainer(contentRef);\n // `undefined` lets Radix fall back to `document.body`.\n const portalContainer = container === undefined ? (fullscreenContainer ?? undefined) : container;\n return (\n <Dialog.Root open={open} onOpenChange={onOpenChange}>\n <Dialog.Portal container={portalContainer}>\n {/* `data-surface=\"overlay\"` lets the glassmorphism style turn this dark\n scrim into a LIGHT, page-blurring backdrop, so the frosted modal\n refracts the (blurred) page behind it instead of darkness — the\n point of a glass modal. Inert for every other style. */}\n <Dialog.Overlay\n data-surface=\"overlay\"\n className={cn('fixed inset-0 z-40 bg-(--el-overlay-scrim)', overlayClassName)}\n />\n {/*\n PRODECT_FINDINGS #8: Radix checks for a describing element\n (Dialog.Description → aria-describedby) independently of the Title\n check. When no `description` prop is passed we render no\n Dialog.Description, so Radix logs a \"Missing Description or\n aria-describedby={undefined}\" warning. We pick the explicit opt-out\n (aria-describedby={undefined}) rather than emitting an empty sr-only\n Description — an empty description announces nothing useful and is\n worse for screen-reader users than declaring the dialog has none.\n */}\n <Dialog.Content\n ref={contentRef}\n className={cn(contentVariants({ size }), className)}\n // `data-surface` lets a surface-material style (glassmorphism) frost\n // the dialog panel — see globals.css's material layer.\n data-surface=\"modal\"\n // A destructive confirm opts into `alertdialog` (2.8.4); otherwise\n // Radix's default `dialog` role applies.\n {...(role ? { role } : {})}\n // Only opt out when there's no description. When `description` is\n // set we omit the prop entirely so Radix keeps auto-wiring\n // aria-describedby to the rendered Dialog.Description's id.\n {...(description ? {} : { 'aria-describedby': undefined })}\n >\n {title || description ? (\n <div className=\"mb-(--spacing-md) shrink-0\">\n {title ? (\n <Dialog.Title className=\"font-serif text-xl font-semibold text-(--el-text)\">\n {title}\n </Dialog.Title>\n ) : null}\n {description ? (\n <Dialog.Description className=\"text-(--el-text-subtitle) mt-1 font-sans text-sm\">\n {description}\n </Dialog.Description>\n ) : null}\n </div>\n ) : (\n // Radix requires Title for a11y; provide a visually-hidden one if\n // missing. `srTitle` lets a consumer with its own visible heading\n // still give the dialog a meaningful accessible name.\n <Dialog.Title className=\"sr-only\">{srTitle ?? dialogLabel}</Dialog.Title>\n )}\n {children}\n {!hideClose ? (\n <Dialog.Close\n aria-label={closeLabel}\n className=\"text-(--el-icon-muted) hover:text-(--el-text) absolute right-3 top-3 rounded-(--radius-control) p-(--spacing-icon-btn) transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--focus-ring-color)\"\n >\n <X className=\"h-4 w-4\" />\n </Dialog.Close>\n ) : null}\n </Dialog.Content>\n </Dialog.Portal>\n </Dialog.Root>\n );\n}\n\nconst ModalFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n function ModalFooter({ className, children, ...rest }, ref) {\n return (\n <div\n ref={ref}\n className={cn(\n 'border-(--el-border) mt-(--spacing-md) flex items-center justify-end gap-2 border-t pt-(--spacing-md)',\n className,\n )}\n {...rest}\n >\n {children}\n </div>\n );\n },\n);\n\n/**\n * ModalBody — the scrollable field area of a modal. Owns the scroll recipe\n * once so consumers don't re-derive it (and re-introduce the focus-ring clip):\n * it fills the remaining column height (the panel caps at `max-h-[90vh]`) and\n * scrolls, so a sibling `Modal.Footer` stays pinned.\n *\n * Ring-safe by construction. `Input` draws its focus ring as\n * `focus-within:ring-2 … ring-offset-2` — a box-shadow extending ~4px OUTSIDE\n * the field's border box. Per the CSS spec, `overflow-y: auto` forces the\n * computed `overflow-x` from `visible` to `auto`, so this body is a clip box on\n * BOTH axes; a full-width field's ring overhang would be painted outside it and\n * silently clipped (rings are box-shadows — they never grow the layout box, so\n * nothing scrolls, the paint is just cut). We pad the scroll container by more\n * than the overhang (`p-1.5` = 6px > 4px) and pull an equal negative margin\n * (`-m-1.5`), so the ring gets room while the body's visual gutter — and the\n * fields' alignment with the modal title/footer — is unchanged.\n *\n * Pass `gap-*` (and any extra layout) via `className`.\n */\nconst ModalBody = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n function ModalBody({ className, children, ...rest }, ref) {\n return (\n <div\n ref={ref}\n className={cn(\n 'flex min-h-0 flex-1 flex-col overflow-y-auto',\n // Ring-safe inset (see the component doc above): padding ≥ the focus\n // ring's ~4px overhang + an equal negative margin to keep the gutter.\n '-m-1.5 p-1.5',\n className,\n )}\n {...rest}\n >\n {children}\n </div>\n );\n },\n);\n\n/** Convenience trigger — wires children to Radix's DialogTrigger. */\nconst ModalTrigger = Dialog.Trigger;\n\nexport const Modal = Object.assign(ModalRoot, {\n Body: ModalBody,\n Footer: ModalFooter,\n Trigger: ModalTrigger,\n});\n"]}
|
|
1
|
+
{"version":3,"sources":["../../../src/utils/cn.ts","../../../src/utils/fullscreen.ts","../../../src/components/ui/Modal.tsx"],"names":["ModalFooter","ModalBody"],"mappings":"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","file":"Modal.js","sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\n/**\n * Merge Tailwind class names without conflicts.\n *\n * `clsx` handles conditional inclusion (booleans, undefineds, arrays);\n * `twMerge` resolves conflicting Tailwind classes (later wins, e.g.\n * `px-2 px-4` → `px-4`). Together they are the de facto standard for\n * composing Tailwind classes in React components.\n *\n * @example\n * cn('px-2 py-1', isActive && 'bg-primary', className)\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","'use client';\n\nimport { useState, useSyncExternalStore, type RefObject } from 'react';\n\n/**\n * The element currently in NATIVE full screen (`document.fullscreenElement`), or\n * `null` — re-read on every `fullscreenchange`.\n *\n * While an element is in native full screen the browser paints ONLY that\n * element's subtree, in the top layer. Anything portalled to `document.body` —\n * a dialog, a popover, a picker's menu — is not shown at all, whatever its\n * z-index (MOTIR-7658: the roadmap canvas's quick view opened invisibly and\n * held focus and scroll lock until Esc).\n *\n * A portalling primitive does not use this directly: it uses\n * `usePortalContainer`, which also refuses to follow full screen into the\n * primitive's own subtree.\n *\n * `null` on the server and in a document with no Fullscreen API.\n */\nexport function useFullscreenElement(): Element | null {\n return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n}\n\nfunction subscribe(onChange: () => void): () => void {\n document.addEventListener('fullscreenchange', onChange);\n return () => document.removeEventListener('fullscreenchange', onChange);\n}\n\nfunction getSnapshot(): Element | null {\n return document.fullscreenElement ?? null;\n}\n\nfunction getServerSnapshot(): null {\n return null;\n}\n\n/**\n * The element a portalling surface mounts into: the element in native full\n * screen while there is one (MOTIR-7658), else `null` — which the caller turns\n * into `document.body`.\n *\n * `own` is the surface's portalled root (a dialog's content, a menu). When the\n * element that goes full screen is INSIDE it — the native full-screen button of\n * a `<video>` in a dialog — the container stays where it was (MOTIR-8560).\n * Following it would re-portal the surface into its own descendant: React\n * unmounts the subtree to move it, the full-screen element leaves the document,\n * the browser exits full screen, and the surface snaps back to a fresh copy.\n *\n * `null` on the server and in a document with no Fullscreen API.\n */\nexport function usePortalContainer(own: RefObject<Element | null>): Element | null {\n const [store] = useState(() => createPortalContainerStore(own));\n return useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot);\n}\n\nfunction createPortalContainerStore(own: RefObject<Element | null>) {\n let container: Element | null =\n typeof document === 'undefined' ? null : (document.fullscreenElement ?? null);\n return {\n subscribe(onChange: () => void): () => void {\n const sync = () => {\n const next = document.fullscreenElement ?? null;\n const root = own.current;\n // The surface's own descendant went full screen: stay put.\n if (next !== null && root !== null && root.contains(next)) return;\n if (next === container) return;\n container = next;\n onChange();\n };\n // A change between creating the store and subscribing to it.\n sync();\n document.addEventListener('fullscreenchange', sync);\n return () => document.removeEventListener('fullscreenchange', sync);\n },\n getSnapshot: (): Element | null => container,\n };\n}\n","'use client';\n\nimport { forwardRef, useRef, type ReactNode } from 'react';\nimport * as Dialog from '@radix-ui/react-dialog';\nimport { X } from 'lucide-react';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '../../utils/cn';\nimport { usePortalContainer } from '../../utils/fullscreen';\n\n/**\n * Modal — accessible dialog wrapping @radix-ui/react-dialog.\n *\n * Radix handles focus trap, ESC-to-close, click-outside-to-close, and\n * focus-return-on-close out of the box. We style on top.\n *\n * Open/close is controlled by the consumer via `open` + `onOpenChange`.\n *\n * For a scrollable body with a pinned footer, wrap the fields in `Modal.Body`\n * (it owns the `flex-1 overflow-y-auto` scroll recipe and keeps focus rings\n * from clipping against the scroll edge — see `ModalBody`).\n *\n * @example\n * const [open, setOpen] = useState(false);\n * <Modal open={open} onOpenChange={setOpen} title=\"Confirm\" size=\"md\">\n * <Modal.Body className=\"gap-4\">\n * <Input label=\"Name\" autoFocus />\n * </Modal.Body>\n * <Modal.Footer>\n * <Button variant=\"ghost\" onClick={() => setOpen(false)}>Cancel</Button>\n * <Button variant=\"primary\" onClick={confirm}>Confirm</Button>\n * </Modal.Footer>\n * </Modal>\n */\nconst contentVariants = cva(\n cn(\n 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',\n 'w-[90vw] rounded-(--radius-modal) bg-(--el-page-bg)',\n 'shadow-(--shadow-modal) border border-(--el-border)',\n 'p-(--spacing-card-padding)',\n 'focus:outline-none',\n // Never exceed the viewport: cap height and lay out as a column so a\n // consumer can wrap its fields in `Modal.Body` (which owns the\n // `flex-1 overflow-y-auto` scroll recipe) and pin a `Modal.Footer`,\n // instead of the dialog growing off-screen (e.g. the create modal's\n // expandable Explanation pushing the Create button out of view).\n 'flex max-h-[90vh] flex-col overflow-hidden',\n ),\n {\n variants: {\n // Literal widths, NOT the max-w-sm/md/lg utilities: the design\n // system's @theme defines --spacing-sm/md/lg (12/16/20px), and\n // Tailwind v4 resolves `max-w-{key}` against the --spacing-* scale\n // when that key exists — so `max-w-md` would collapse the modal to\n // 16px wide. Pinning the rem values (Tailwind's stock sm/md/lg) keeps\n // the design-system token set untouched and the dialog readable.\n size: {\n sm: 'max-w-[24rem]',\n md: 'max-w-[28rem]',\n lg: 'max-w-[32rem]',\n // The large peek surface (Subtask 2.5.19's quick view) — a generous\n // dialog that takes a big part of the screen; pairs with a height +\n // p-0 className on the consumer side. Stock Tailwind 4xl rem value, so\n // the design-system --spacing-* scale stays untouched (see the comment\n // above on why we pin rems instead of max-w-{key}).\n xl: 'max-w-[58rem]',\n // The whole viewport (Subtask 5.2.6's attachment preview lightbox —\n // the 2.5.19 growth pattern one step further). Like `xl`, the variant\n // only lifts the size caps; the consumer pairs it with bg/border/\n // radius/padding overrides to drop the panel chrome it doesn't want.\n full: 'h-dvh max-h-dvh w-screen max-w-none',\n },\n },\n defaultVariants: { size: 'md' },\n },\n);\n\nexport interface ModalProps extends VariantProps<typeof contentVariants> {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title?: string;\n description?: string;\n children?: ReactNode;\n /** Hide the default close (×) button in the corner. */\n hideClose?: boolean;\n /**\n * Accessible name for a dialog that renders its OWN visible heading (so no\n * `title` is passed). Radix requires a Dialog.Title; without `title`/`srTitle`\n * we fall back to the generic \"Dialog\". Supply this to label the dialog with\n * something meaningful (e.g. the previewed issue's key) while keeping the\n * heading inside `children` (Subtask 2.5.19's quick-view peek).\n */\n srTitle?: string;\n className?: string;\n /**\n * Extra classes merged onto the backdrop — e.g. the attachment preview\n * lightbox (Subtask 5.2.6) deepens the default `--el-overlay-scrim` to\n * `bg-black/80` per `design/work-items/attachments.mock.html` panel 6.\n */\n overlayClassName?: string;\n /**\n * The dialog's ARIA role. Defaults to Radix's `dialog`; a DESTRUCTIVE confirm\n * passes `alertdialog` (Subtask 2.8.4's delete confirm) so assistive tech\n * treats it as an alert that interrupts and announces its description.\n */\n role?: 'dialog' | 'alertdialog';\n /**\n * Accessible label for the corner close (×) button. Defaults to `'Close'`.\n * i18n is the consuming app's concern — the design-system package is\n * framework-agnostic (no `next-intl` dependency), so a localized app passes\n * the translated string here (motir-core wires it from its `common.close`).\n */\n closeLabel?: string;\n /**\n * Fallback accessible name when neither `title` nor `srTitle` is given.\n * Defaults to `'Dialog'`. See `closeLabel` for the i18n rationale.\n */\n dialogLabel?: string;\n /**\n * The element the dialog portals into. Defaults to the element in NATIVE full\n * screen while there is one (`document.fullscreenElement` — the browser paints\n * nothing outside it, so a body-portalled dialog would open invisibly,\n * MOTIR-7658), else `document.body` — unless that element is inside the\n * dialog itself, where the dialog stays put (MOTIR-8560). Pass `null` to force\n * `document.body`.\n */\n container?: Element | null;\n /**\n * More element ids that DESCRIBE the dialog, added to its `aria-describedby`\n * after the `description`'s own id (MOTIR-10544; design MOTIR-10543's\n * can't-plan alert). For a dialog whose meaning lives in its BODY as well as\n * its one-line description — a list of reasons — so assistive tech reads the\n * body with the title rather than only when the reader tabs into it. A\n * space-separated id list, as `aria-describedby` takes it.\n */\n describedBy?: string;\n /**\n * Where focus goes when the dialog opens — Radix's `onOpenAutoFocus`. Call\n * `event.preventDefault()` and focus an element yourself to land on a control\n * other than the first focusable one (an alert lands on its safe action).\n */\n onOpenAutoFocus?: (event: Event) => void;\n /**\n * Where focus goes when the dialog closes — Radix's `onCloseAutoFocus`. A\n * dialog opened WITHOUT a `Modal.Trigger` has nothing for Radix to return\n * focus to, so a consumer that opened it from a control calls\n * `event.preventDefault()` and focuses that control here.\n */\n onCloseAutoFocus?: (event: Event) => void;\n}\n\nfunction ModalRoot({\n open,\n onOpenChange,\n title,\n description,\n size,\n hideClose,\n srTitle,\n className,\n overlayClassName,\n role,\n children,\n closeLabel = 'Close',\n dialogLabel = 'Dialog',\n container,\n describedBy,\n onOpenAutoFocus,\n onCloseAutoFocus,\n}: ModalProps) {\n // The dialog's own content: a full-screen element inside it (a `<video>`'s\n // native full-screen button) must not pull the dialog into itself (MOTIR-8560).\n const contentRef = useRef<HTMLDivElement>(null);\n const fullscreenContainer = usePortalContainer(contentRef);\n // `undefined` lets Radix fall back to `document.body`.\n const portalContainer = container === undefined ? (fullscreenContainer ?? undefined) : container;\n // With `describedBy` the description's id is OURS, so the content can name it\n // beside the extra ids; without it Radix wires its own id (unchanged). Derived\n // from the caller's id rather than a `useId` of our own, so a Modal without\n // `describedBy` draws no extra id and every other `useId` keeps its value.\n const descriptionId = describedBy ? `${describedBy}-description` : undefined;\n const describedByIds = describedBy\n ? [description ? descriptionId : null, describedBy].filter(Boolean).join(' ')\n : null;\n return (\n <Dialog.Root open={open} onOpenChange={onOpenChange}>\n <Dialog.Portal container={portalContainer}>\n {/* `data-surface=\"overlay\"` lets the glassmorphism style turn this dark\n scrim into a LIGHT, page-blurring backdrop, so the frosted modal\n refracts the (blurred) page behind it instead of darkness — the\n point of a glass modal. Inert for every other style. */}\n <Dialog.Overlay\n data-surface=\"overlay\"\n className={cn('fixed inset-0 z-40 bg-(--el-overlay-scrim)', overlayClassName)}\n />\n {/*\n PRODECT_FINDINGS #8: Radix checks for a describing element\n (Dialog.Description → aria-describedby) independently of the Title\n check. When no `description` prop is passed we render no\n Dialog.Description, so Radix logs a \"Missing Description or\n aria-describedby={undefined}\" warning. We pick the explicit opt-out\n (aria-describedby={undefined}) rather than emitting an empty sr-only\n Description — an empty description announces nothing useful and is\n worse for screen-reader users than declaring the dialog has none.\n */}\n <Dialog.Content\n ref={contentRef}\n className={cn(contentVariants({ size }), className)}\n // `data-surface` lets a surface-material style (glassmorphism) frost\n // the dialog panel — see globals.css's material layer.\n data-surface=\"modal\"\n // A destructive confirm opts into `alertdialog` (2.8.4); otherwise\n // Radix's default `dialog` role applies.\n {...(role ? { role } : {})}\n // Only opt out when there's no description. When `description` is\n // set we omit the prop entirely so Radix keeps auto-wiring\n // aria-describedby to the rendered Dialog.Description's id.\n {...(describedByIds\n ? { 'aria-describedby': describedByIds }\n : description\n ? {}\n : { 'aria-describedby': undefined })}\n {...(onOpenAutoFocus ? { onOpenAutoFocus } : {})}\n {...(onCloseAutoFocus ? { onCloseAutoFocus } : {})}\n >\n {title || description ? (\n <div className=\"mb-(--spacing-md) shrink-0\">\n {title ? (\n <Dialog.Title className=\"font-serif text-xl font-semibold text-(--el-text)\">\n {title}\n </Dialog.Title>\n ) : null}\n {description ? (\n <Dialog.Description\n {...(describedByIds ? { id: descriptionId } : {})}\n className=\"text-(--el-text-subtitle) mt-1 font-sans text-sm\"\n >\n {description}\n </Dialog.Description>\n ) : null}\n </div>\n ) : (\n // Radix requires Title for a11y; provide a visually-hidden one if\n // missing. `srTitle` lets a consumer with its own visible heading\n // still give the dialog a meaningful accessible name.\n <Dialog.Title className=\"sr-only\">{srTitle ?? dialogLabel}</Dialog.Title>\n )}\n {children}\n {!hideClose ? (\n <Dialog.Close\n aria-label={closeLabel}\n className=\"text-(--el-icon-muted) hover:text-(--el-text) absolute right-3 top-3 rounded-(--radius-control) p-(--spacing-icon-btn) transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--focus-ring-color)\"\n >\n <X className=\"h-4 w-4\" />\n </Dialog.Close>\n ) : null}\n </Dialog.Content>\n </Dialog.Portal>\n </Dialog.Root>\n );\n}\n\nconst ModalFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n function ModalFooter({ className, children, ...rest }, ref) {\n return (\n <div\n ref={ref}\n className={cn(\n 'border-(--el-border) mt-(--spacing-md) flex items-center justify-end gap-2 border-t pt-(--spacing-md)',\n className,\n )}\n {...rest}\n >\n {children}\n </div>\n );\n },\n);\n\n/**\n * ModalBody — the scrollable field area of a modal. Owns the scroll recipe\n * once so consumers don't re-derive it (and re-introduce the focus-ring clip):\n * it fills the remaining column height (the panel caps at `max-h-[90vh]`) and\n * scrolls, so a sibling `Modal.Footer` stays pinned.\n *\n * Ring-safe by construction. `Input` draws its focus ring as\n * `focus-within:ring-2 … ring-offset-2` — a box-shadow extending ~4px OUTSIDE\n * the field's border box. Per the CSS spec, `overflow-y: auto` forces the\n * computed `overflow-x` from `visible` to `auto`, so this body is a clip box on\n * BOTH axes; a full-width field's ring overhang would be painted outside it and\n * silently clipped (rings are box-shadows — they never grow the layout box, so\n * nothing scrolls, the paint is just cut). We pad the scroll container by more\n * than the overhang (`p-1.5` = 6px > 4px) and pull an equal negative margin\n * (`-m-1.5`), so the ring gets room while the body's visual gutter — and the\n * fields' alignment with the modal title/footer — is unchanged.\n *\n * Pass `gap-*` (and any extra layout) via `className`.\n */\nconst ModalBody = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n function ModalBody({ className, children, ...rest }, ref) {\n return (\n <div\n ref={ref}\n className={cn(\n 'flex min-h-0 flex-1 flex-col overflow-y-auto',\n // Ring-safe inset (see the component doc above): padding ≥ the focus\n // ring's ~4px overhang + an equal negative margin to keep the gutter.\n '-m-1.5 p-1.5',\n className,\n )}\n {...rest}\n >\n {children}\n </div>\n );\n },\n);\n\n/** Convenience trigger — wires children to Radix's DialogTrigger. */\nconst ModalTrigger = Dialog.Trigger;\n\nexport const Modal = Object.assign(ModalRoot, {\n Body: ModalBody,\n Footer: ModalFooter,\n Trigger: ModalTrigger,\n});\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motir/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Motir's 3-axis design system (colour · style · type): distributable token CSS, the axis registries, the theme-apply API, and framework-agnostic React primitives. Shared by motir-core and planner-scaffolded products.",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"repository": {
|
package/theme.css
CHANGED
|
@@ -3703,6 +3703,20 @@
|
|
|
3703
3703
|
colour. NOT danger: nothing was lost or destroyed — the repository exists and
|
|
3704
3704
|
the plan is safe; the user simply cannot open it yet. */
|
|
3705
3705
|
--el-notif-access-refused: var(--color-warning);
|
|
3706
|
+
/* An organisation owner or admin HIBERNATED the recipient's agent (MOTIR-10368,
|
|
3707
|
+
per design/org-admin/design-notes.md, the Fleet delta's one token). Info, not
|
|
3708
|
+
warning or danger: nothing was lost — the files are kept and the owner can
|
|
3709
|
+
wake it — but somebody else acted on their agent and they should know. */
|
|
3710
|
+
--el-notif-agent-hibernated: var(--color-info);
|
|
3711
|
+
/* A review waits for the routed person's OWN agent (Story MOTIR-10327 · MOTIR-10340).
|
|
3712
|
+
--color-warning, the hue of the review markers' loud "awaiting" form, because the row
|
|
3713
|
+
says the same thing they do: something is waiting on you. Its own slot, as the
|
|
3714
|
+
access row has its own, so a palette can move one without the other. */
|
|
3715
|
+
--el-notif-review-to-run: var(--color-warning);
|
|
3716
|
+
/* An agent SENT BACK the recipient's card (Story MOTIR-10327 · MOTIR-10338).
|
|
3717
|
+
--color-destructive, the hue of the To fix tag the send-back puts on the card: the row
|
|
3718
|
+
says the card was refused and is now theirs to repair. Its own slot, as above. */
|
|
3719
|
+
--el-notif-review-sent-back: var(--color-destructive);
|
|
3706
3720
|
/* The EXECUTOR badge on a Workbench row's assignee avatar (Story MOTIR-2649 ·
|
|
3707
3721
|
Subtask MOTIR-2653, per design/workbench/design-notes.md §"The agent state"
|
|
3708
3722
|
— that area was renamed with the surface by MOTIR-4779).
|