@ds-mo/ui 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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/dist/index.css +1 -0
  4. package/dist/index.d.ts +969 -0
  5. package/dist/index.js +2623 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/r/accordion.json +79 -0
  8. package/dist/r/badge.json +69 -0
  9. package/dist/r/banner.json +90 -0
  10. package/dist/r/breadcrumb.json +70 -0
  11. package/dist/r/button-group.json +65 -0
  12. package/dist/r/button.json +137 -0
  13. package/dist/r/card.json +82 -0
  14. package/dist/r/checkbox.json +91 -0
  15. package/dist/r/divider.json +57 -0
  16. package/dist/r/empty-state.json +69 -0
  17. package/dist/r/error-boundary.json +66 -0
  18. package/dist/r/fade.json +65 -0
  19. package/dist/r/field.json +71 -0
  20. package/dist/r/header.json +67 -0
  21. package/dist/r/input.json +77 -0
  22. package/dist/r/loader.json +54 -0
  23. package/dist/r/menu.json +130 -0
  24. package/dist/r/modal.json +93 -0
  25. package/dist/r/pagination.json +80 -0
  26. package/dist/r/radio.json +85 -0
  27. package/dist/r/registry.json +1600 -0
  28. package/dist/r/scrollbar.json +68 -0
  29. package/dist/r/select.json +93 -0
  30. package/dist/r/sidebar.json +105 -0
  31. package/dist/r/skeleton.json +72 -0
  32. package/dist/r/slider.json +87 -0
  33. package/dist/r/surface.json +97 -0
  34. package/dist/r/tab-group.json +72 -0
  35. package/dist/r/tab.json +71 -0
  36. package/dist/r/table.json +110 -0
  37. package/dist/r/tag.json +110 -0
  38. package/dist/r/text.json +94 -0
  39. package/dist/r/toast.json +77 -0
  40. package/dist/r/toggle-button-group.json +65 -0
  41. package/dist/r/toggle-button.json +94 -0
  42. package/dist/r/toggle.json +65 -0
  43. package/dist/r/tooltip.json +86 -0
  44. package/package.json +80 -0
  45. package/src/components/Accordion/Accordion.module.css +66 -0
  46. package/src/components/Accordion/Accordion.stories.tsx +95 -0
  47. package/src/components/Accordion/Accordion.tsx +122 -0
  48. package/src/components/Accordion/index.ts +2 -0
  49. package/src/components/Badge/Badge.module.css +6 -0
  50. package/src/components/Badge/Badge.stories.tsx +99 -0
  51. package/src/components/Badge/Badge.tsx +23 -0
  52. package/src/components/Badge/index.ts +2 -0
  53. package/src/components/Banner/Banner.module.css +67 -0
  54. package/src/components/Banner/Banner.stories.tsx +81 -0
  55. package/src/components/Banner/Banner.tsx +109 -0
  56. package/src/components/Banner/index.ts +2 -0
  57. package/src/components/Breadcrumb/Breadcrumb.module.css +46 -0
  58. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +72 -0
  59. package/src/components/Breadcrumb/Breadcrumb.tsx +68 -0
  60. package/src/components/Breadcrumb/index.ts +2 -0
  61. package/src/components/Button/Button.module.css +343 -0
  62. package/src/components/Button/Button.stories.tsx +575 -0
  63. package/src/components/Button/Button.tsx +201 -0
  64. package/src/components/Button/index.ts +10 -0
  65. package/src/components/ButtonGroup/ButtonGroup.module.css +157 -0
  66. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +384 -0
  67. package/src/components/ButtonGroup/ButtonGroup.tsx +122 -0
  68. package/src/components/ButtonGroup/index.ts +2 -0
  69. package/src/components/Card/Card.module.css +36 -0
  70. package/src/components/Card/Card.stories.tsx +78 -0
  71. package/src/components/Card/Card.tsx +53 -0
  72. package/src/components/Card/index.ts +2 -0
  73. package/src/components/Checkbox/Checkbox.module.css +47 -0
  74. package/src/components/Checkbox/Checkbox.stories.tsx +129 -0
  75. package/src/components/Checkbox/Checkbox.tsx +68 -0
  76. package/src/components/Checkbox/index.ts +2 -0
  77. package/src/components/Divider/Divider.module.css +19 -0
  78. package/src/components/Divider/Divider.stories.tsx +55 -0
  79. package/src/components/Divider/Divider.tsx +28 -0
  80. package/src/components/Divider/index.ts +2 -0
  81. package/src/components/EmptyState/EmptyState.module.css +8 -0
  82. package/src/components/EmptyState/EmptyState.stories.tsx +53 -0
  83. package/src/components/EmptyState/EmptyState.tsx +31 -0
  84. package/src/components/EmptyState/index.ts +2 -0
  85. package/src/components/ErrorBoundary/ErrorBoundary.module.css +18 -0
  86. package/src/components/ErrorBoundary/ErrorBoundary.stories.tsx +38 -0
  87. package/src/components/ErrorBoundary/ErrorBoundary.tsx +50 -0
  88. package/src/components/ErrorBoundary/index.ts +2 -0
  89. package/src/components/Fade/Fade.module.css +31 -0
  90. package/src/components/Fade/Fade.stories.tsx +35 -0
  91. package/src/components/Fade/Fade.tsx +33 -0
  92. package/src/components/Fade/index.ts +2 -0
  93. package/src/components/Field/Field.module.css +5 -0
  94. package/src/components/Field/Field.stories.tsx +111 -0
  95. package/src/components/Field/Field.tsx +22 -0
  96. package/src/components/Field/index.ts +2 -0
  97. package/src/components/Header/Header.module.css +33 -0
  98. package/src/components/Header/Header.stories.tsx +52 -0
  99. package/src/components/Header/Header.tsx +42 -0
  100. package/src/components/Header/index.ts +2 -0
  101. package/src/components/Input/Input.module.css +71 -0
  102. package/src/components/Input/Input.stories.tsx +135 -0
  103. package/src/components/Input/Input.tsx +76 -0
  104. package/src/components/Input/index.ts +2 -0
  105. package/src/components/LabelWrap/LabelWrap.module.css +34 -0
  106. package/src/components/LabelWrap/LabelWrap.tsx +32 -0
  107. package/src/components/LabelWrap/index.ts +1 -0
  108. package/src/components/Loader/Loader.module.css +9 -0
  109. package/src/components/Loader/Loader.stories.tsx +44 -0
  110. package/src/components/Loader/Loader.tsx +43 -0
  111. package/src/components/Loader/index.ts +2 -0
  112. package/src/components/Menu/DestructiveMenuItem.module.css +57 -0
  113. package/src/components/Menu/DestructiveMenuItem.tsx +64 -0
  114. package/src/components/Menu/Menu.module.css +49 -0
  115. package/src/components/Menu/Menu.stories.tsx +129 -0
  116. package/src/components/Menu/Menu.tsx +269 -0
  117. package/src/components/Menu/MenuItem.module.css +84 -0
  118. package/src/components/Menu/MenuItem.tsx +101 -0
  119. package/src/components/Menu/index.ts +6 -0
  120. package/src/components/Modal/Modal.module.css +67 -0
  121. package/src/components/Modal/Modal.stories.tsx +71 -0
  122. package/src/components/Modal/Modal.tsx +122 -0
  123. package/src/components/Modal/index.ts +2 -0
  124. package/src/components/Pagination/Pagination.module.css +64 -0
  125. package/src/components/Pagination/Pagination.stories.tsx +51 -0
  126. package/src/components/Pagination/Pagination.tsx +103 -0
  127. package/src/components/Pagination/index.ts +2 -0
  128. package/src/components/Radio/Radio.module.css +61 -0
  129. package/src/components/Radio/Radio.stories.tsx +101 -0
  130. package/src/components/Radio/Radio.tsx +108 -0
  131. package/src/components/Radio/index.ts +2 -0
  132. package/src/components/Scrollbar/Scrollbar.module.css +81 -0
  133. package/src/components/Scrollbar/Scrollbar.stories.tsx +46 -0
  134. package/src/components/Scrollbar/Scrollbar.tsx +187 -0
  135. package/src/components/Scrollbar/index.ts +2 -0
  136. package/src/components/Select/Select.module.css +31 -0
  137. package/src/components/Select/Select.stories.tsx +59 -0
  138. package/src/components/Select/Select.tsx +92 -0
  139. package/src/components/Select/index.ts +2 -0
  140. package/src/components/Sidebar/Sidebar.module.css +161 -0
  141. package/src/components/Sidebar/Sidebar.stories.tsx +125 -0
  142. package/src/components/Sidebar/Sidebar.tsx +129 -0
  143. package/src/components/Sidebar/SidebarItem.tsx +57 -0
  144. package/src/components/Sidebar/SidebarSection.tsx +42 -0
  145. package/src/components/Sidebar/index.ts +6 -0
  146. package/src/components/Skeleton/Skeleton.module.css +53 -0
  147. package/src/components/Skeleton/Skeleton.stories.tsx +89 -0
  148. package/src/components/Skeleton/Skeleton.tsx +66 -0
  149. package/src/components/Skeleton/index.ts +2 -0
  150. package/src/components/Slider/Slider.module.css +62 -0
  151. package/src/components/Slider/Slider.stories.tsx +120 -0
  152. package/src/components/Slider/Slider.tsx +74 -0
  153. package/src/components/Slider/index.ts +2 -0
  154. package/src/components/Surface/Surface.module.css +222 -0
  155. package/src/components/Surface/Surface.stories.tsx +213 -0
  156. package/src/components/Surface/Surface.tsx +219 -0
  157. package/src/components/Surface/index.ts +12 -0
  158. package/src/components/Tab/Tab.module.css +66 -0
  159. package/src/components/Tab/Tab.stories.tsx +103 -0
  160. package/src/components/Tab/Tab.tsx +41 -0
  161. package/src/components/Tab/index.ts +2 -0
  162. package/src/components/TabGroup/TabGroup.module.css +26 -0
  163. package/src/components/TabGroup/TabGroup.stories.tsx +60 -0
  164. package/src/components/TabGroup/TabGroup.tsx +83 -0
  165. package/src/components/TabGroup/index.ts +2 -0
  166. package/src/components/Table/Table.module.css +130 -0
  167. package/src/components/Table/Table.stories.tsx +111 -0
  168. package/src/components/Table/Table.tsx +226 -0
  169. package/src/components/Table/index.ts +11 -0
  170. package/src/components/Tag/Tag.module.css +270 -0
  171. package/src/components/Tag/Tag.stories.tsx +399 -0
  172. package/src/components/Tag/Tag.tsx +162 -0
  173. package/src/components/Tag/index.ts +2 -0
  174. package/src/components/Text/Text.module.css +166 -0
  175. package/src/components/Text/Text.stories.tsx +212 -0
  176. package/src/components/Text/Text.tsx +181 -0
  177. package/src/components/Text/index.ts +13 -0
  178. package/src/components/Toast/Toast.module.css +91 -0
  179. package/src/components/Toast/Toast.stories.tsx +66 -0
  180. package/src/components/Toast/Toast.tsx +182 -0
  181. package/src/components/Toast/index.ts +8 -0
  182. package/src/components/Toggle/Toggle.module.css +87 -0
  183. package/src/components/Toggle/Toggle.stories.tsx +85 -0
  184. package/src/components/Toggle/Toggle.tsx +52 -0
  185. package/src/components/Toggle/index.ts +2 -0
  186. package/src/components/ToggleButton/ToggleButton.module.css +172 -0
  187. package/src/components/ToggleButton/ToggleButton.stories.tsx +299 -0
  188. package/src/components/ToggleButton/ToggleButton.tsx +118 -0
  189. package/src/components/ToggleButton/index.ts +2 -0
  190. package/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css +163 -0
  191. package/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx +341 -0
  192. package/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx +111 -0
  193. package/src/components/ToggleButtonGroup/index.ts +2 -0
  194. package/src/components/Tooltip/Tooltip.module.css +45 -0
  195. package/src/components/Tooltip/Tooltip.stories.tsx +46 -0
  196. package/src/components/Tooltip/Tooltip.tsx +224 -0
  197. package/src/components/Tooltip/index.ts +2 -0
  198. package/src/css-modules.d.ts +4 -0
  199. package/src/docs/ColorUsage.mdx +195 -0
  200. package/src/docs/ElevationUsage.mdx +95 -0
  201. package/src/docs/Introduction.mdx +97 -0
  202. package/src/docs/OpticalSizing.mdx +178 -0
  203. package/src/docs/TypographyUsage.mdx +121 -0
  204. package/src/index.ts +154 -0
  205. package/src/stories/Colors.stories.tsx +528 -0
  206. package/src/stories/Dimensions.stories.tsx +200 -0
  207. package/src/stories/Effects.stories.tsx +231 -0
  208. package/src/stories/Icons.stories.tsx +159 -0
  209. package/src/stories/Typography.stories.tsx +157 -0
  210. package/src/types/icons.ts +15 -0
  211. package/src/types/index.ts +1 -0
  212. package/src/utils/cn.ts +6 -0
  213. package/src/utils/css-tokens.ts +22 -0
@@ -0,0 +1,77 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "input",
4
+ "title": "Input",
5
+ "description": "Text input supporting text, email, tel, url, search, and password types. Search type includes auto clear button.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/input",
11
+ "consumption": {
12
+ "install": "npm install @compomo/ui @ds-mo/tokens",
13
+ "cssSetup": "Import tokens CSS at your app entry point: import '@ds-mo/tokens/css';\nThis provides all var(--color-*), var(--dimension-*), var(--effect-*), and var(--typography-*) custom properties.\nFor dark mode, set data-theme=\"dark\" on <html>.",
14
+ "import": "import { Input } from '@compomo/ui';",
15
+ "cssImport": "import '@compomo/ui/css';",
16
+ "typeImport": "import type { InputProps } from '@compomo/ui';",
17
+ "peerDependencies": {
18
+ "required": [
19
+ "react >=17",
20
+ "react-dom >=17",
21
+ "@ds-mo/tokens >=0.1.0"
22
+ ],
23
+ "optional": []
24
+ }
25
+ },
26
+ "props": {
27
+ "value": {
28
+ "type": "string",
29
+ "required": true
30
+ },
31
+ "onChange": {
32
+ "type": "(e: React.ChangeEvent<HTMLInputElement>) => void",
33
+ "required": true
34
+ },
35
+ "type": {
36
+ "type": "'text' | 'email' | 'tel' | 'url' | 'search' | 'password'",
37
+ "default": "'text'"
38
+ },
39
+ "placeholder": {
40
+ "type": "string"
41
+ },
42
+ "suffix": {
43
+ "type": "React.ReactNode"
44
+ },
45
+ "inactive": {
46
+ "type": "boolean"
47
+ },
48
+ "clearIcon": {
49
+ "type": "IconComponent",
50
+ "description": "Custom clear icon for search type."
51
+ }
52
+ },
53
+ "exports": [
54
+ "Input"
55
+ ],
56
+ "types": [
57
+ "InputProps"
58
+ ]
59
+ },
60
+ "dependencies": [
61
+ "@compomo/ui",
62
+ "@ds-mo/tokens"
63
+ ],
64
+ "registryDependencies": [],
65
+ "files": [
66
+ {
67
+ "path": "src/components/Input/Input.module.css",
68
+ "content": ".wrapper {\n width: 100%;\n height: var(--dimension-size-400);\n display: flex;\n align-items: center;\n background-color: var(--color-background-primary);\n border-radius: var(--dimension-radius-075);\n box-shadow: var(--effect-elevation-depressed-sm);\n box-sizing: border-box;\n}\n\n.wrapper:focus-within {\n box-shadow: inset 0 0 0 1.25px var(--color-foreground-bold-brand);\n}\n\n.row {\n display: flex;\n align-items: center;\n width: 100%;\n height: 100%;\n gap: var(--dimension-space-050);\n}\n\n.input {\n flex: 1;\n min-width: 0;\n height: 100%;\n padding: 0 var(--dimension-space-100);\n background: transparent;\n border: none;\n outline: none;\n font-family: inherit;\n font-size: var(--typography-fontsize-md);\n line-height: var(--typography-lineheight-md);\n color: var(--color-foreground-primary);\n border-radius: var(--dimension-radius-075);\n -webkit-appearance: none;\n appearance: none;\n}\n\n.input:disabled { opacity: 0.5; cursor: not-allowed; }\n.input::placeholder { color: var(--color-foreground-secondary); }\n.input:focus::placeholder { color: var(--color-foreground-tertiary); }\n\n/* Hide native search clear */\n.input[type=\"search\"]::-webkit-search-cancel-button,\n.input[type=\"search\"]::-webkit-search-decoration { display: none; }\n\n.clearButton {\n flex-shrink: 0;\n margin-right: var(--dimension-space-050);\n padding: var(--dimension-space-050);\n border: none;\n background: none;\n cursor: pointer;\n color: var(--color-foreground-secondary);\n border-radius: var(--dimension-radius-050);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.clearButton:hover { background-color: var(--color-interaction-hover); }\n\n.suffix {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050);\n flex-shrink: 0;\n margin-right: var(--dimension-space-050);\n}\n",
69
+ "type": "registry:ui"
70
+ },
71
+ {
72
+ "path": "src/components/Input/Input.tsx",
73
+ "content": "import React, { forwardRef } from 'react';\nimport { cn } from '@/utils/cn';\nimport type { IconComponent } from '@/types/icons';\nimport styles from './Input.module.css';\n\nexport type InputType = 'text' | 'email' | 'tel' | 'url' | 'search' | 'password';\n\nexport interface InputProps {\n value: string;\n onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n id?: string;\n placeholder?: string;\n autoFocus?: boolean;\n className?: string;\n type?: InputType;\n inactive?: boolean;\n onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;\n /** Content rendered inside the field on the right. */\n suffix?: React.ReactNode;\n /** Icon for clear button (search type). */\n clearIcon?: IconComponent;\n 'aria-label'?: string;\n}\n\nexport const Input = forwardRef<HTMLDivElement, InputProps>(\n (\n {\n value,\n onChange,\n id,\n placeholder,\n autoFocus = false,\n className,\n type = 'text',\n inactive = false,\n onKeyDown,\n suffix,\n clearIcon: ClearIcon,\n 'aria-label': ariaLabel,\n },\n ref\n ) => {\n const showClear = type === 'search' && value.length > 0 && !inactive;\n\n const handleClear = () => {\n onChange({ target: { value: '' } } as React.ChangeEvent<HTMLInputElement>);\n };\n\n return (\n <div ref={ref} className={cn(styles.wrapper, className)}>\n <div className={styles.row}>\n <input\n type={type}\n id={id}\n value={value}\n onChange={onChange}\n onKeyDown={onKeyDown}\n placeholder={placeholder}\n className={styles.input}\n autoFocus={autoFocus}\n disabled={inactive}\n aria-label={ariaLabel}\n />\n {showClear && (\n <button type=\"button\" className={styles.clearButton} onClick={handleClear} aria-label=\"Clear\">\n {ClearIcon ? <ClearIcon size={16} /> : <span aria-hidden>&times;</span>}\n </button>\n )}\n {suffix && <div className={styles.suffix}>{suffix}</div>}\n </div>\n </div>\n );\n }\n);\n\nInput.displayName = 'Input';\n",
74
+ "type": "registry:ui"
75
+ }
76
+ ]
77
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "loader",
4
+ "title": "Loader",
5
+ "description": "SVG spinner that scales proportionally. Uses currentColor for theming.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/loader",
11
+ "consumption": {
12
+ "install": "npm install @compomo/ui",
13
+ "import": "import { Loader } from '@compomo/ui';",
14
+ "cssImport": "import '@compomo/ui/css';",
15
+ "typeImport": "import type { LoaderProps } from '@compomo/ui';",
16
+ "peerDependencies": {
17
+ "required": [
18
+ "react >=17",
19
+ "react-dom >=17",
20
+ "@ds-mo/tokens >=0.1.0"
21
+ ],
22
+ "optional": []
23
+ }
24
+ },
25
+ "props": {
26
+ "size": {
27
+ "type": "number",
28
+ "default": "20"
29
+ }
30
+ },
31
+ "exports": [
32
+ "Loader"
33
+ ],
34
+ "types": [
35
+ "LoaderProps"
36
+ ]
37
+ },
38
+ "dependencies": [
39
+ "@compomo/ui"
40
+ ],
41
+ "registryDependencies": [],
42
+ "files": [
43
+ {
44
+ "path": "src/components/Loader/Loader.module.css",
45
+ "content": ".loader {\n flex-shrink: 0;\n animation: spin 0.75s linear infinite;\n}\n\n@keyframes spin {\n from { transform: rotate(0deg); }\n to { transform: rotate(360deg); }\n}\n",
46
+ "type": "registry:ui"
47
+ },
48
+ {
49
+ "path": "src/components/Loader/Loader.tsx",
50
+ "content": "import React from 'react';\nimport styles from './Loader.module.css';\n\nexport interface LoaderProps {\n /**\n * Render size in px. Designed at 16px (r=6, strokeWidth=1).\n * Stroke weight scales proportionally with size — same behaviour as icons.\n * Defaults to 20.\n */\n size?: number;\n className?: string;\n}\n\n/**\n * Placeholder spinner component. Will be replaced with a full Loader\n * component once motion/brand specs are finalised.\n *\n * Designed at 16×16 viewBox so it matches the icon grid:\n * - r=5 → 10px circle in a 16px box (3px optical padding each side)\n * - strokeWidth=1 at native size → scales to 1.25px at 20px, 1.5px at 24px\n */\nexport const Loader = ({ size = 20, className }: LoaderProps) => (\n <svg\n width={size}\n height={size}\n viewBox=\"0 0 16 16\"\n fill=\"none\"\n className={`${styles.loader}${className ? ` ${className}` : ''}`}\n aria-hidden=\"true\"\n >\n <circle\n cx=\"8\"\n cy=\"8\"\n r=\"5\"\n stroke=\"currentColor\"\n strokeWidth=\"1\"\n strokeLinecap=\"round\"\n strokeDasharray=\"23.6 7.9\"\n />\n </svg>\n);\n\nLoader.displayName = 'Loader';\n",
51
+ "type": "registry:ui"
52
+ }
53
+ ]
54
+ }
@@ -0,0 +1,130 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "menu",
4
+ "title": "Menu",
5
+ "description": "Positioned dropdown menu with sections, item selection styles, toggle items, and destructive hold-to-confirm items.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/menu",
11
+ "consumption": {
12
+ "install": "npm install @compomo/ui @ds-mo/tokens",
13
+ "cssSetup": "Import tokens CSS at your app entry point: import '@ds-mo/tokens/css';\nThis provides all var(--color-*), var(--dimension-*), var(--effect-*), and var(--typography-*) custom properties.\nFor dark mode, set data-theme=\"dark\" on <html>.",
14
+ "import": "import { Menu, MenuItem, DestructiveMenuItem } from '@compomo/ui';",
15
+ "cssImport": "import '@compomo/ui/css';",
16
+ "typeImport": "import type { MenuProps, MenuSection, MenuItemData, MenuSide, MenuAlign, MenuItemProps, MenuItemSelectionStyle, DestructiveMenuItemProps } from '@compomo/ui';",
17
+ "internalDependencies": [
18
+ {
19
+ "component": "Surface",
20
+ "note": "Used internally by Menu. No action needed — it's bundled in @compomo/ui."
21
+ },
22
+ {
23
+ "component": "Text",
24
+ "note": "Used internally by Menu. No action needed — it's bundled in @compomo/ui."
25
+ },
26
+ {
27
+ "component": "Toggle",
28
+ "note": "Used internally by Menu. No action needed — it's bundled in @compomo/ui."
29
+ }
30
+ ],
31
+ "peerDependencies": {
32
+ "required": [
33
+ "react >=17",
34
+ "react-dom >=17",
35
+ "@ds-mo/tokens >=0.1.0"
36
+ ],
37
+ "optional": []
38
+ }
39
+ },
40
+ "props": {
41
+ "isOpen": {
42
+ "type": "boolean",
43
+ "required": true
44
+ },
45
+ "onClose": {
46
+ "type": "() => void",
47
+ "required": true
48
+ },
49
+ "anchorRef": {
50
+ "type": "React.RefObject<HTMLElement | null>",
51
+ "required": true
52
+ },
53
+ "items": {
54
+ "type": "MenuItemData[]",
55
+ "description": "Flat item list (use items or sections, not both)."
56
+ },
57
+ "sections": {
58
+ "type": "MenuSection[]",
59
+ "description": "Grouped items with optional headers."
60
+ },
61
+ "side": {
62
+ "type": "'top' | 'right' | 'bottom' | 'left'",
63
+ "default": "'bottom'"
64
+ },
65
+ "align": {
66
+ "type": "'start' | 'center' | 'end'",
67
+ "default": "'start'"
68
+ },
69
+ "matchAnchorWidth": {
70
+ "type": "boolean"
71
+ }
72
+ },
73
+ "exports": [
74
+ "Menu",
75
+ "MenuItem",
76
+ "DestructiveMenuItem"
77
+ ],
78
+ "types": [
79
+ "MenuProps",
80
+ "MenuSection",
81
+ "MenuItemData",
82
+ "MenuSide",
83
+ "MenuAlign",
84
+ "MenuItemProps",
85
+ "MenuItemSelectionStyle",
86
+ "DestructiveMenuItemProps"
87
+ ]
88
+ },
89
+ "dependencies": [
90
+ "@compomo/ui",
91
+ "@ds-mo/tokens"
92
+ ],
93
+ "registryDependencies": [
94
+ "surface",
95
+ "text",
96
+ "toggle"
97
+ ],
98
+ "files": [
99
+ {
100
+ "path": "src/components/Menu/Menu.tsx",
101
+ "content": "import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react';\nimport { createPortal } from 'react-dom';\nimport { Text } from '@/components/Text';\nimport { Surface } from '@/components/Surface';\nimport { MenuItem as MenuItemComponent } from './MenuItem';\nimport { DestructiveMenuItem } from './DestructiveMenuItem';\nimport { getCssSpacePx } from '@/utils/css-tokens';\nimport type { IconComponent } from '@/types';\nimport type { MenuItemSelectionStyle } from './MenuItem';\nimport styles from './Menu.module.css';\n\nconst CLOSE_ANIMATION_MS = 220;\n\nexport type MenuSide = 'top' | 'right' | 'bottom' | 'left';\nexport type MenuAlign = 'start' | 'center' | 'end';\n\nexport interface MenuItemData {\n label: string;\n onClick: () => void;\n isSelected?: boolean;\n isDestructive?: boolean;\n isInactive?: boolean;\n intent?: 'negative';\n keepOpenOnClick?: boolean;\n holdDuration?: number;\n icon?: IconComponent;\n subtext?: string;\n showToggle?: boolean;\n toggleValue?: boolean;\n selectionStyle?: MenuItemSelectionStyle;\n}\n\nexport interface MenuSection {\n header?: string;\n items: MenuItemData[];\n}\n\nexport interface MenuProps {\n isOpen: boolean;\n onClose: () => void;\n anchorRef: React.RefObject<HTMLElement | null>;\n items?: MenuItemData[];\n sections?: MenuSection[];\n side?: MenuSide;\n align?: MenuAlign;\n sideOffset?: number;\n alignOffset?: number;\n sideOffsetToken?: string;\n alignOffsetToken?: string;\n width?: string;\n minWidth?: string;\n maxWidth?: string;\n matchAnchorWidth?: boolean;\n matchAnchorWidthOffset?: number;\n usePortal?: boolean;\n}\n\nexport const Menu: React.FC<MenuProps> = ({\n isOpen,\n onClose,\n anchorRef,\n items,\n sections,\n side = 'bottom',\n align = 'start',\n sideOffset = 4,\n alignOffset = 0,\n sideOffsetToken,\n alignOffsetToken,\n width,\n minWidth,\n maxWidth,\n matchAnchorWidth = false,\n matchAnchorWidthOffset = 0,\n usePortal = true,\n}) => {\n const [isClosing, setIsClosing] = useState(false);\n const [shouldRender, setShouldRender] = useState(isOpen);\n const [isPositionReady, setIsPositionReady] = useState(false);\n const [position, setPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });\n const [menuSize, setMenuSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });\n const [anchorSize, setAnchorSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });\n const positionCalculatedRef = useRef(false);\n const menuRef = useRef<HTMLDivElement>(null);\n const lastSectionsRef = useRef<MenuSection[]>([]);\n\n const menuSections: MenuSection[] = sections || (items ? [{ items }] : []);\n\n const hasItems = menuSections.some((s) => s.items.length > 0);\n if (!isClosing && menuSections.length > 0 && hasItems) {\n lastSectionsRef.current = menuSections;\n }\n const sectionsToRender = isClosing ? lastSectionsRef.current : menuSections;\n\n useEffect(() => {\n if (isOpen) {\n setShouldRender(true);\n setIsClosing(false);\n setIsPositionReady(false);\n positionCalculatedRef.current = false;\n } else if (shouldRender) {\n setIsClosing(true);\n const timer = setTimeout(() => {\n setShouldRender(false);\n setIsClosing(false);\n positionCalculatedRef.current = false;\n }, CLOSE_ANIMATION_MS);\n return () => clearTimeout(timer);\n }\n }, [isOpen, shouldRender]);\n\n useLayoutEffect(() => {\n if (!isOpen || !shouldRender || !menuRef.current) return;\n const updateSize = () => {\n if (!menuRef.current) return;\n const rect = menuRef.current.getBoundingClientRect();\n if (rect.width > 0 && rect.height > 0) setMenuSize({ width: rect.width, height: rect.height });\n };\n updateSize();\n requestAnimationFrame(() => requestAnimationFrame(updateSize));\n if (typeof ResizeObserver !== 'undefined') {\n const observer = new ResizeObserver(updateSize);\n observer.observe(menuRef.current);\n return () => observer.disconnect();\n }\n }, [isOpen, shouldRender, menuSections.length]);\n\n useLayoutEffect(() => {\n if (!isOpen || !shouldRender || !anchorRef.current || !menuRef.current) return;\n const calculatePosition = () => {\n const anchor = anchorRef.current;\n const menu = menuRef.current;\n if (!anchor || !menu) return;\n const anchorRect = anchor.getBoundingClientRect();\n const menuRect = menu.getBoundingClientRect();\n const menuWidth = menuRect.width || menuSize.width || 200;\n const menuHeight = menuRect.height || menuSize.height || 160;\n setAnchorSize({ width: anchorRect.width, height: anchorRect.height });\n const gap = sideOffsetToken != null ? getCssSpacePx(sideOffsetToken, 12) : sideOffset;\n const alignOff = alignOffsetToken != null ? getCssSpacePx(alignOffsetToken, 4) : alignOffset;\n let x = 0, y = 0;\n switch (side) {\n case 'top':\n y = anchorRect.top - menuHeight - gap;\n x = align === 'start' ? anchorRect.left + alignOff : align === 'end' ? anchorRect.right - menuWidth + alignOff : anchorRect.left + anchorRect.width / 2 - menuWidth / 2 + alignOff;\n break;\n case 'bottom':\n y = anchorRect.bottom + gap;\n x = align === 'start' ? anchorRect.left + alignOff : align === 'end' ? anchorRect.right - menuWidth + alignOff : anchorRect.left + anchorRect.width / 2 - menuWidth / 2 + alignOff;\n break;\n case 'left':\n x = anchorRect.left - menuWidth - gap;\n y = align === 'start' ? anchorRect.top + alignOff : align === 'end' ? anchorRect.bottom - menuHeight + alignOff : anchorRect.top + anchorRect.height / 2 - menuHeight / 2 + alignOff;\n break;\n case 'right':\n x = anchorRect.right + gap;\n y = align === 'start' ? anchorRect.top + alignOff : align === 'end' ? anchorRect.bottom - menuHeight + alignOff : anchorRect.top + anchorRect.height / 2 - menuHeight / 2 + alignOff;\n break;\n }\n const vp = 4;\n setPosition({\n x: Math.min(Math.max(x, vp), window.innerWidth - menuWidth - vp),\n y: Math.min(Math.max(y, vp), window.innerHeight - menuHeight - vp),\n });\n positionCalculatedRef.current = true;\n setIsPositionReady(true);\n };\n if (!positionCalculatedRef.current) {\n calculatePosition();\n requestAnimationFrame(() => { if (!positionCalculatedRef.current) calculatePosition(); });\n }\n const handleScrollResize = () => calculatePosition();\n window.addEventListener('scroll', handleScrollResize, true);\n window.addEventListener('resize', handleScrollResize);\n return () => {\n window.removeEventListener('scroll', handleScrollResize, true);\n window.removeEventListener('resize', handleScrollResize);\n };\n }, [isOpen, shouldRender, anchorRef, side, align, sideOffset, alignOffset, sideOffsetToken, alignOffsetToken, menuSize.width, menuSize.height, menuSections.length, matchAnchorWidthOffset]);\n\n useEffect(() => {\n if (!isOpen || !shouldRender) return;\n const handleClickOutside = (event: MouseEvent) => {\n const target = event.target as HTMLElement;\n if (menuRef.current?.contains(target)) return;\n if (anchorRef.current?.contains(target)) return;\n onClose();\n };\n document.addEventListener('mousedown', handleClickOutside, true);\n return () => document.removeEventListener('mousedown', handleClickOutside, true);\n }, [isOpen, shouldRender, onClose, anchorRef]);\n\n useEffect(() => {\n if (!isOpen) return;\n const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };\n document.addEventListener('keydown', handleEscape);\n return () => document.removeEventListener('keydown', handleEscape);\n }, [isOpen, onClose]);\n\n if (!shouldRender || sectionsToRender.length === 0) return null;\n\n const renderMenuItem = (item: MenuItemData, index: number) => {\n if (item.isDestructive) {\n return (\n <DestructiveMenuItem\n key={index}\n label={item.label}\n subtext={item.subtext}\n holdDuration={item.holdDuration ?? 4000}\n onClick={() => { item.onClick(); if (!item.keepOpenOnClick) onClose(); }}\n />\n );\n }\n return (\n <MenuItemComponent\n key={index}\n icon={item.icon}\n label={item.label}\n subtext={item.subtext}\n onClick={() => { if (!item.isInactive) { item.onClick(); if (!item.keepOpenOnClick) onClose(); } }}\n isSelected={item.isSelected}\n isInactive={item.isInactive}\n intent={item.intent}\n showToggle={item.showToggle}\n toggleValue={item.toggleValue}\n selectionStyle={item.selectionStyle}\n />\n );\n };\n\n const menuStyle: React.CSSProperties = {\n position: 'absolute',\n left: 0,\n top: 0,\n transform: `translate(${Math.round(position.x)}px, ${Math.round(position.y)}px)`,\n willChange: 'transform',\n zIndex: 9998,\n visibility: isPositionReady ? 'visible' : 'hidden',\n ...(matchAnchorWidth && anchorSize.width > 0 ? { width: `${anchorSize.width + matchAnchorWidthOffset}px` } : width ? { width } : {}),\n ...(minWidth ? { minWidth } : {}),\n ...(maxWidth ? { maxWidth } : {}),\n };\n\n const menuContent = (\n <div ref={menuRef} className={`${styles.menuContainer} ${isClosing ? styles.closing : ''}`} style={menuStyle} role=\"menu\" aria-label=\"Menu\">\n {sectionsToRender.map((section, sectionIndex) => (\n <Surface\n key={sectionIndex}\n as=\"div\"\n className={styles.menuSection}\n edge={sectionIndex < sectionsToRender.length - 1 ? 'bottom' : undefined}\n elevation=\"none\"\n >\n {section.header && (\n <div className={styles.sectionHeader}>\n <Text variant=\"text-body-small-emphasis\" as=\"span\" className={styles.sectionLabel}>{section.header}</Text>\n </div>\n )}\n {section.items.map((item, itemIndex) => renderMenuItem(item, itemIndex))}\n </Surface>\n ))}\n </div>\n );\n\n if (usePortal) return createPortal(menuContent, document.body);\n return menuContent;\n};\n\nMenu.displayName = 'Menu';\n",
102
+ "type": "registry:ui"
103
+ },
104
+ {
105
+ "path": "src/components/Menu/Menu.module.css",
106
+ "content": ".menuContainer {\n box-sizing: border-box;\n background-color: var(--color-background-primary);\n border-radius: var(--dimension-radius-125);\n box-shadow: var(--effect-elevation-elevated-floating);\n display: flex;\n flex-direction: column;\n min-width: 200px;\n max-width: 600px;\n overflow: hidden;\n animation: menuFadeIn var(--effect-motion-short-2) forwards;\n backface-visibility: hidden;\n -webkit-font-smoothing: subpixel-antialiased;\n}\n\n@keyframes menuFadeIn {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n.menuContainer.closing {\n animation: menuFadeOut var(--effect-motion-short-3) forwards;\n}\n\n@keyframes menuFadeOut {\n from { opacity: 1; }\n to { opacity: 0; }\n}\n\n.menuSection {\n padding: var(--dimension-space-050);\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-050);\n}\n\n.sectionHeader {\n padding: var(--dimension-space-050) var(--dimension-space-075);\n display: flex;\n align-items: center;\n height: var(--dimension-size-400);\n -webkit-user-select: none;\n user-select: none;\n}\n\n.sectionLabel {\n color: var(--color-foreground-secondary);\n text-align: left;\n}\n",
107
+ "type": "registry:ui"
108
+ },
109
+ {
110
+ "path": "src/components/Menu/MenuItem.tsx",
111
+ "content": "import React, { forwardRef } from 'react';\nimport { Text } from '@/components/Text';\nimport { Surface } from '@/components/Surface';\nimport type { IconComponent } from '@/types';\nimport styles from './MenuItem.module.css';\n\nexport type MenuItemSelectionStyle = 'highlight' | 'radio' | 'noOverlay';\n\nexport interface MenuItemProps {\n label: string;\n onClick: () => void;\n isSelected?: boolean;\n isInactive?: boolean;\n icon?: IconComponent;\n labelColor?: 'primary' | 'secondary' | 'tertiary';\n subtext?: string;\n showToggle?: boolean;\n toggleValue?: boolean;\n intent?: 'negative';\n selectionStyle?: MenuItemSelectionStyle;\n showTrailingChevron?: boolean;\n checkIcon?: IconComponent;\n chevronIcon?: IconComponent;\n}\n\nexport const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(\n ({\n label,\n onClick,\n isSelected = false,\n isInactive = false,\n icon: Icon,\n labelColor,\n subtext,\n showToggle = false,\n toggleValue = false,\n intent,\n selectionStyle = 'highlight',\n showTrailingChevron = false,\n checkIcon: CheckIcon,\n chevronIcon: ChevronIcon,\n }, ref) => {\n const useRadioStyle = selectionStyle === 'radio';\n const noOverlay = selectionStyle === 'noOverlay' || useRadioStyle;\n const showSelectedOverlay = isSelected && !noOverlay;\n\n return (\n <Surface\n ref={ref as React.Ref<HTMLElement>}\n as=\"button\"\n interactive={!isInactive}\n selected={showSelectedOverlay}\n radius=\"sm\"\n className={`${styles.menuItem} ${isSelected ? styles.menuItemSelected : ''} ${isInactive ? styles.menuItemInactive : ''} ${intent === 'negative' ? styles.menuItemNegative : ''} ${labelColor === 'tertiary' ? styles.menuItemLabelTertiary : ''}`}\n onClick={onClick}\n type=\"button\"\n inactive={isInactive}\n >\n {Icon && (\n <div className={styles.iconPrefix}>\n <Icon size={20} />\n </div>\n )}\n <div className={styles.content}>\n <Text\n variant={isSelected ? 'text-body-medium-emphasis' : 'text-body-medium'}\n color={labelColor}\n as=\"span\"\n className={styles.label}\n >\n {label}\n </Text>\n {subtext && (\n <Text variant=\"text-body-small\" as=\"span\" className={styles.subtext}>\n {subtext}\n </Text>\n )}\n </div>\n {showToggle && (\n <div className={styles.toggleSuffix}>\n <div className={`${styles.toggle} ${toggleValue ? styles.toggleOn : ''}`}>\n <div className={styles.toggleThumb} />\n </div>\n </div>\n )}\n {useRadioStyle && isSelected && CheckIcon && (\n <div className={styles.checkSuffix}>\n <CheckIcon size={20} />\n </div>\n )}\n {showTrailingChevron && ChevronIcon && (\n <div className={styles.chevronSuffix}>\n <ChevronIcon size={20} />\n </div>\n )}\n </Surface>\n );\n }\n);\n\nMenuItem.displayName = 'MenuItem';\n",
112
+ "type": "registry:ui"
113
+ },
114
+ {
115
+ "path": "src/components/Menu/MenuItem.module.css",
116
+ "content": ".menuItem {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050);\n padding: var(--dimension-space-075);\n border: none;\n cursor: pointer;\n -webkit-user-select: none;\n user-select: none;\n text-align: left;\n width: 100%;\n min-height: var(--dimension-size-400);\n}\n\n.menuItem:has(.subtext) { align-items: flex-start; }\n\n.iconPrefix {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n color: var(--color-foreground-secondary);\n}\n\n.menuItemSelected .iconPrefix { color: var(--color-foreground-primary); }\n\n.content {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-025);\n min-width: 0;\n}\n\n.label { color: var(--color-foreground-secondary); }\n.menuItem:has(.subtext) .label { color: var(--color-foreground-primary); }\n.menuItemSelected .label { color: var(--color-foreground-primary) !important; }\n.menuItemLabelTertiary .label, .menuItemLabelTertiary .iconPrefix { color: var(--color-foreground-tertiary); }\n.menuItemNegative .label { color: var(--color-foreground-bold-negative) !important; }\n\n.subtext { color: var(--color-foreground-secondary); line-height: 1.4; }\n\n.menuItemInactive { opacity: 0.5; cursor: default; pointer-events: none; }\n\n.toggleSuffix { flex-shrink: 0; display: flex; align-items: center; }\n\n.toggle {\n width: calc(var(--dimension-size-400) + var(--dimension-size-050));\n height: var(--dimension-size-250);\n background-color: var(--color-background-translucent);\n border-radius: var(--dimension-radius-125);\n position: relative;\n transition: var(--effect-transition-interaction-background-short-2);\n}\n\n.toggleOn { background-color: var(--color-background-medium-brand); }\n\n.toggleThumb {\n width: var(--dimension-size-200);\n height: var(--dimension-size-200);\n background-color: var(--color-foreground-on-bold-background-primary);\n border-radius: 50%;\n position: absolute;\n top: var(--dimension-space-025);\n left: var(--dimension-space-025);\n transition: transform var(--effect-motion-short-2);\n}\n\n.toggleOn .toggleThumb { transform: translateX(var(--dimension-offset-200)); }\n\n.checkSuffix {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n color: var(--color-foreground-primary);\n}\n\n.chevronSuffix {\n flex-shrink: 0;\n display: flex;\n align-items: center;\n color: var(--color-foreground-secondary);\n}\n\n.menuItemSelected .chevronSuffix { color: var(--color-foreground-primary); }\n",
117
+ "type": "registry:ui"
118
+ },
119
+ {
120
+ "path": "src/components/Menu/DestructiveMenuItem.tsx",
121
+ "content": "import React, { forwardRef, useState, useRef, useEffect } from 'react';\nimport { Text } from '@/components/Text';\nimport styles from './DestructiveMenuItem.module.css';\n\nexport interface DestructiveMenuItemProps {\n label: string;\n onClick: () => void;\n holdDuration?: number;\n subtext?: string;\n}\n\nexport const DestructiveMenuItem = forwardRef<HTMLButtonElement, DestructiveMenuItemProps>(\n ({ label, onClick, holdDuration = 4000, subtext }, ref) => {\n const [progress, setProgress] = useState(0);\n const pressTimerRef = useRef<number | null>(null);\n const startTimeRef = useRef<number>(0);\n const animationFrameRef = useRef<number | null>(null);\n\n const resetState = () => {\n setProgress(0);\n if (pressTimerRef.current) { clearTimeout(pressTimerRef.current); pressTimerRef.current = null; }\n if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; }\n };\n\n const handleMouseDown = (e: React.MouseEvent) => {\n e.stopPropagation();\n e.preventDefault();\n if (holdDuration === 0) { onClick(); return; }\n startTimeRef.current = performance.now();\n const animate = () => {\n const elapsed = performance.now() - startTimeRef.current;\n const newProgress = Math.min((elapsed / holdDuration) * 100, 100);\n setProgress(newProgress);\n if (newProgress < 100) animationFrameRef.current = requestAnimationFrame(animate);\n };\n animationFrameRef.current = requestAnimationFrame(animate);\n pressTimerRef.current = window.setTimeout(() => { onClick(); resetState(); }, holdDuration);\n };\n\n const handleMouseUp = (e: React.MouseEvent) => { e.stopPropagation(); resetState(); };\n const handleMouseLeave = (e: React.MouseEvent) => { e.stopPropagation(); resetState(); };\n\n useEffect(() => () => { resetState(); }, []);\n\n return (\n <button\n ref={ref}\n className={styles.destructiveMenuItem}\n onMouseDown={handleMouseDown}\n onMouseUp={handleMouseUp}\n onMouseLeave={handleMouseLeave}\n type=\"button\"\n >\n <div className={styles.progressFill} style={{ width: `${progress}%` }} />\n <div className={styles.content}>\n <Text variant=\"text-body-medium\" as=\"span\" className={styles.label}>{label}</Text>\n {subtext && <Text variant=\"text-body-small\" as=\"span\" className={styles.subtext}>{subtext}</Text>}\n </div>\n </button>\n );\n }\n);\n\nDestructiveMenuItem.displayName = 'DestructiveMenuItem';\n",
122
+ "type": "registry:ui"
123
+ },
124
+ {
125
+ "path": "src/components/Menu/DestructiveMenuItem.module.css",
126
+ "content": ".destructiveMenuItem {\n display: flex;\n align-items: center;\n padding: var(--dimension-space-075);\n background-color: transparent;\n border: none;\n border-radius: var(--dimension-radius-075);\n cursor: pointer;\n -webkit-user-select: none;\n user-select: none;\n text-align: left;\n width: 100%;\n min-height: var(--dimension-size-400);\n position: relative;\n transition: var(--effect-transition-interaction-background-instant);\n overflow: hidden;\n}\n\n.destructiveMenuItem:has(.subtext) { align-items: flex-start; }\n\n.progressFill {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n background-color: var(--color-background-faint-negative);\n border-radius: var(--dimension-radius-075);\n transition: width 0ms linear;\n pointer-events: none;\n z-index: 0;\n}\n\n.destructiveMenuItem::before {\n content: '';\n position: absolute;\n inset: 0;\n background-color: transparent;\n border-radius: var(--dimension-radius-075);\n transition: var(--effect-transition-interaction-background-instant);\n pointer-events: none;\n z-index: 1;\n}\n\n.content {\n position: relative;\n z-index: 2;\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-025);\n min-width: 0;\n}\n\n.label { color: var(--color-foreground-bold-negative); }\n.subtext { color: var(--color-foreground-secondary); text-wrap: pretty; }\n\n.destructiveMenuItem:hover::before { background-color: var(--color-interaction-hover); }\n.destructiveMenuItem:active::before { background-color: transparent; }\n",
127
+ "type": "registry:ui"
128
+ }
129
+ ]
130
+ }
@@ -0,0 +1,93 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "modal",
4
+ "title": "Modal",
5
+ "description": "Dialog overlay with portal rendering, escape-to-close, backdrop click, focus management, and entry/exit animations.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/modal",
11
+ "consumption": {
12
+ "install": "npm install @compomo/ui @ds-mo/tokens",
13
+ "cssSetup": "Import tokens CSS at your app entry point: import '@ds-mo/tokens/css';\nThis provides all var(--color-*), var(--dimension-*), var(--effect-*), and var(--typography-*) custom properties.\nFor dark mode, set data-theme=\"dark\" on <html>.",
14
+ "import": "import { Modal } from '@compomo/ui';",
15
+ "cssImport": "import '@compomo/ui/css';",
16
+ "typeImport": "import type { ModalProps, ModalWidth } from '@compomo/ui';",
17
+ "internalDependencies": [
18
+ {
19
+ "component": "Surface",
20
+ "note": "Used internally by Modal. No action needed — it's bundled in @compomo/ui."
21
+ },
22
+ {
23
+ "component": "Text",
24
+ "note": "Used internally by Modal. No action needed — it's bundled in @compomo/ui."
25
+ }
26
+ ],
27
+ "peerDependencies": {
28
+ "required": [
29
+ "react >=17",
30
+ "react-dom >=17",
31
+ "@ds-mo/tokens >=0.1.0"
32
+ ],
33
+ "optional": []
34
+ }
35
+ },
36
+ "props": {
37
+ "isOpen": {
38
+ "type": "boolean",
39
+ "required": true
40
+ },
41
+ "onClose": {
42
+ "type": "() => void",
43
+ "required": true
44
+ },
45
+ "title": {
46
+ "type": "string",
47
+ "required": true
48
+ },
49
+ "children": {
50
+ "type": "React.ReactNode",
51
+ "required": true
52
+ },
53
+ "subtitle": {
54
+ "type": "React.ReactNode"
55
+ },
56
+ "footer": {
57
+ "type": "React.ReactNode"
58
+ },
59
+ "width": {
60
+ "type": "'sm' | 'md' | 'lg' | string",
61
+ "default": "'md'",
62
+ "description": "sm=400px, md=560px, lg=720px, or custom CSS value."
63
+ }
64
+ },
65
+ "exports": [
66
+ "Modal"
67
+ ],
68
+ "types": [
69
+ "ModalProps",
70
+ "ModalWidth"
71
+ ]
72
+ },
73
+ "dependencies": [
74
+ "@compomo/ui",
75
+ "@ds-mo/tokens"
76
+ ],
77
+ "registryDependencies": [
78
+ "surface",
79
+ "text"
80
+ ],
81
+ "files": [
82
+ {
83
+ "path": "src/components/Modal/Modal.module.css",
84
+ "content": ".backdrop {\n position: fixed;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: var(--dimension-space-200);\n background-color: var(--color-background-shade);\n backdrop-filter: blur(var(--effect-blur-sm));\n -webkit-backdrop-filter: blur(var(--effect-blur-sm));\n animation: modalBackdropFadeIn var(--effect-motion-short-2) forwards;\n z-index: 9999;\n}\n\n.backdrop.closing {\n animation: modalBackdropFadeOut var(--effect-motion-short-3) forwards;\n}\n\n@keyframes modalBackdropFadeIn {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n@keyframes modalBackdropFadeOut {\n from { opacity: 1; }\n to { opacity: 0; }\n}\n\n.dialog {\n display: flex;\n flex-direction: column;\n max-height: calc(100vh - 32px);\n overflow: hidden;\n animation: modalDialogFadeIn var(--effect-motion-short-2) forwards;\n}\n\n.backdrop.closing .dialog {\n animation: modalDialogFadeOut var(--effect-motion-short-3) forwards;\n}\n\n@keyframes modalDialogFadeIn {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n@keyframes modalDialogFadeOut {\n from { opacity: 1; }\n to { opacity: 0; }\n}\n\n.header {\n padding: var(--dimension-space-200) var(--dimension-space-200) var(--dimension-space-100) var(--dimension-space-200);\n}\n\n.subtitle {\n margin-top: var(--dimension-space-100);\n text-wrap: balance;\n}\n\n.body {\n padding: var(--dimension-space-100) var(--dimension-space-200);\n overflow-y: auto;\n}\n\n.footer {\n padding: var(--dimension-space-100) var(--dimension-space-200) var(--dimension-space-200) var(--dimension-space-200);\n}\n",
85
+ "type": "registry:ui"
86
+ },
87
+ {
88
+ "path": "src/components/Modal/Modal.tsx",
89
+ "content": "import React, { useEffect, useId, useMemo, useRef, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { Surface } from '@/components/Surface';\nimport { Text } from '@/components/Text';\nimport styles from './Modal.module.css';\n\nexport type ModalWidth = 'sm' | 'md' | 'lg';\n\nconst WIDTH_MAP: Record<ModalWidth, string> = {\n sm: 'var(--dimension-modal-width-sm, 400px)',\n md: 'var(--dimension-modal-width-md, 560px)',\n lg: 'var(--dimension-modal-width-lg, 720px)',\n};\n\nconst CLOSE_ANIMATION_MS = 220;\n\nexport interface ModalProps {\n isOpen: boolean;\n onClose: () => void;\n title: string;\n subtitle?: React.ReactNode;\n children: React.ReactNode;\n footer?: React.ReactNode;\n width?: ModalWidth | string;\n bodyClassName?: string;\n}\n\nexport const Modal: React.FC<ModalProps> = ({\n isOpen,\n onClose,\n title,\n subtitle,\n children,\n footer,\n width = 'md',\n bodyClassName,\n}) => {\n const dialogRef = useRef<HTMLDivElement>(null);\n const titleId = useId();\n const [isClosing, setIsClosing] = useState(false);\n const [shouldRender, setShouldRender] = useState(isOpen);\n\n const resolvedWidth = useMemo(() => {\n if (width === 'sm' || width === 'md' || width === 'lg') {\n return WIDTH_MAP[width];\n }\n return width;\n }, [width]);\n\n useEffect(() => {\n if (isOpen) {\n setShouldRender(true);\n setIsClosing(false);\n } else if (shouldRender) {\n setIsClosing(true);\n const timer = setTimeout(() => {\n setShouldRender(false);\n setIsClosing(false);\n }, CLOSE_ANIMATION_MS);\n return () => clearTimeout(timer);\n }\n }, [isOpen, shouldRender]);\n\n useEffect(() => {\n if (!isOpen) return;\n const handleEscape = (e: KeyboardEvent) => {\n if (e.key === 'Escape') onClose();\n };\n document.addEventListener('keydown', handleEscape);\n return () => document.removeEventListener('keydown', handleEscape);\n }, [isOpen, onClose]);\n\n useEffect(() => {\n if (!isOpen || !shouldRender) return;\n const id = requestAnimationFrame(() => {\n dialogRef.current?.focus();\n });\n return () => cancelAnimationFrame(id);\n }, [isOpen, shouldRender]);\n\n if (!shouldRender) return null;\n\n return createPortal(\n <div\n className={`${styles.backdrop} ${isClosing ? styles.closing : ''}`}\n onMouseDown={(e) => {\n if (e.target === e.currentTarget) onClose();\n }}\n >\n <Surface\n ref={dialogRef}\n background=\"primary\"\n elevation=\"floating\"\n radius=\"var(--dimension-radius-275, 22px)\"\n className={styles.dialog}\n style={{ width: `min(${resolvedWidth}, calc(100vw - 32px))` }}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={titleId}\n tabIndex={-1}\n >\n <div className={styles.header}>\n <Text id={titleId} as=\"h2\" variant=\"text-title-small\">\n {title}\n </Text>\n {subtitle != null && (\n <div className={styles.subtitle}>{subtitle}</div>\n )}\n </div>\n <div className={`${styles.body} ${bodyClassName ?? ''}`.trim()}>\n {children}\n </div>\n {footer != null && (\n <div className={styles.footer}>{footer}</div>\n )}\n </Surface>\n </div>,\n document.body\n );\n};\n\nModal.displayName = 'Modal';\n",
90
+ "type": "registry:ui"
91
+ }
92
+ ]
93
+ }
@@ -0,0 +1,80 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "pagination",
4
+ "title": "Pagination",
5
+ "description": "Standalone page navigation with ellipsis for large ranges, sibling count control, and keyboard-accessible buttons.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/pagination",
11
+ "consumption": {
12
+ "install": "npm install @compomo/ui @ds-mo/tokens",
13
+ "cssSetup": "Import tokens CSS at your app entry point: import '@ds-mo/tokens/css';\nThis provides all var(--color-*), var(--dimension-*), var(--effect-*), and var(--typography-*) custom properties.\nFor dark mode, set data-theme=\"dark\" on <html>.",
14
+ "import": "import { Pagination } from '@compomo/ui';",
15
+ "cssImport": "import '@compomo/ui/css';",
16
+ "typeImport": "import type { PaginationProps } from '@compomo/ui';",
17
+ "internalDependencies": [
18
+ {
19
+ "component": "Text",
20
+ "note": "Used internally by Pagination. No action needed — it's bundled in @compomo/ui."
21
+ }
22
+ ],
23
+ "peerDependencies": {
24
+ "required": [
25
+ "react >=17",
26
+ "react-dom >=17",
27
+ "@ds-mo/tokens >=0.1.0"
28
+ ],
29
+ "optional": []
30
+ }
31
+ },
32
+ "props": {
33
+ "page": {
34
+ "type": "number",
35
+ "required": true,
36
+ "description": "1-based current page."
37
+ },
38
+ "totalPages": {
39
+ "type": "number",
40
+ "required": true
41
+ },
42
+ "onPageChange": {
43
+ "type": "(page: number) => void",
44
+ "required": true
45
+ },
46
+ "siblingCount": {
47
+ "type": "number",
48
+ "default": "1"
49
+ },
50
+ "inactive": {
51
+ "type": "boolean"
52
+ }
53
+ },
54
+ "exports": [
55
+ "Pagination"
56
+ ],
57
+ "types": [
58
+ "PaginationProps"
59
+ ]
60
+ },
61
+ "dependencies": [
62
+ "@compomo/ui",
63
+ "@ds-mo/tokens"
64
+ ],
65
+ "registryDependencies": [
66
+ "text"
67
+ ],
68
+ "files": [
69
+ {
70
+ "path": "src/components/Pagination/Pagination.module.css",
71
+ "content": ".pagination {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-025);\n -webkit-user-select: none;\n user-select: none;\n}\n\n.inactive {\n opacity: 0.5;\n pointer-events: none;\n}\n\n/* ─── Button ────────────────────────────────────────────────────────────────── */\n\n.button {\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: var(--dimension-size-300);\n height: var(--dimension-size-300);\n padding: 0 var(--dimension-space-050);\n background: none;\n border: none;\n border-radius: var(--dimension-radius-075);\n cursor: pointer;\n color: var(--color-foreground-secondary);\n transition: background-color var(--effect-motion-short-2), color var(--effect-motion-short-2);\n}\n\n.button:hover:not(:disabled) {\n background-color: var(--color-interaction-hover);\n color: var(--color-foreground-primary);\n}\n\n.button:active:not(:disabled) {\n background-color: var(--color-interaction-pressed);\n}\n\n.button:disabled {\n cursor: not-allowed;\n opacity: 0.35;\n}\n\n.active {\n background-color: var(--color-interaction-hover);\n color: var(--color-foreground-primary);\n}\n\n/* ─── Nav arrows ────────────────────────────────────────────────────────────── */\n\n.nav {\n font-size: 18px;\n}\n\n/* ─── Ellipsis ──────────────────────────────────────────────────────────────── */\n\n.ellipsis {\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: var(--dimension-size-300);\n height: var(--dimension-size-300);\n}\n",
72
+ "type": "registry:ui"
73
+ },
74
+ {
75
+ "path": "src/components/Pagination/Pagination.tsx",
76
+ "content": "import React, { forwardRef, useMemo } from 'react';\nimport { cn } from '@/utils/cn';\nimport { Text } from '@/components/Text';\nimport styles from './Pagination.module.css';\n\nexport interface PaginationProps {\n /** Current page (1-based). */\n page: number;\n /** Total number of pages. */\n totalPages: number;\n /** Called when page changes. */\n onPageChange: (page: number) => void;\n /** Max page buttons visible (excluding prev/next). */\n siblingCount?: number;\n /** Disables all interaction. */\n inactive?: boolean;\n className?: string;\n}\n\nfunction buildRange(start: number, end: number): (number | 'ellipsis')[] {\n const items: (number | 'ellipsis')[] = [];\n for (let i = start; i <= end; i++) items.push(i);\n return items;\n}\n\nfunction buildPages(page: number, totalPages: number, siblings: number): (number | 'ellipsis')[] {\n const totalSlots = siblings * 2 + 5; // siblings + boundaries + ellipses + current\n\n if (totalPages <= totalSlots) return buildRange(1, totalPages);\n\n const leftSibling = Math.max(page - siblings, 2);\n const rightSibling = Math.min(page + siblings, totalPages - 1);\n\n const showLeftEllipsis = leftSibling > 2;\n const showRightEllipsis = rightSibling < totalPages - 1;\n\n if (!showLeftEllipsis && showRightEllipsis) {\n const left = buildRange(1, siblings * 2 + 3);\n return [...left, 'ellipsis', totalPages];\n }\n\n if (showLeftEllipsis && !showRightEllipsis) {\n const right = buildRange(totalPages - (siblings * 2 + 2), totalPages);\n return [1, 'ellipsis', ...right];\n }\n\n return [1, 'ellipsis', ...buildRange(leftSibling, rightSibling), 'ellipsis', totalPages];\n}\n\nexport const Pagination = forwardRef<HTMLElement, PaginationProps>(\n ({ page, totalPages, onPageChange, siblingCount = 1, inactive = false, className }, ref) => {\n const pages = useMemo(() => buildPages(page, totalPages, siblingCount), [page, totalPages, siblingCount]);\n\n if (totalPages <= 1) return null;\n\n return (\n <nav ref={ref} aria-label=\"Pagination\" className={cn(styles.pagination, inactive && styles.inactive, className)}>\n <button\n type=\"button\"\n className={cn(styles.button, styles.nav)}\n disabled={page <= 1 || inactive}\n onClick={() => onPageChange(page - 1)}\n aria-label=\"Previous page\"\n >\n <Text variant=\"text-body-small\" as=\"span\" color=\"inherit\">&#x2039;</Text>\n </button>\n\n {pages.map((item, i) =>\n item === 'ellipsis' ? (\n <span key={`e${i}`} className={styles.ellipsis}>\n <Text variant=\"text-body-small\" as=\"span\" color=\"tertiary\">&hellip;</Text>\n </span>\n ) : (\n <button\n key={item}\n type=\"button\"\n className={cn(styles.button, item === page && styles.active)}\n onClick={() => onPageChange(item)}\n aria-current={item === page ? 'page' : undefined}\n disabled={inactive}\n >\n <Text variant={item === page ? 'text-body-small-emphasis' : 'text-body-small'} as=\"span\" color=\"inherit\">\n {item}\n </Text>\n </button>\n )\n )}\n\n <button\n type=\"button\"\n className={cn(styles.button, styles.nav)}\n disabled={page >= totalPages || inactive}\n onClick={() => onPageChange(page + 1)}\n aria-label=\"Next page\"\n >\n <Text variant=\"text-body-small\" as=\"span\" color=\"inherit\">&#x203A;</Text>\n </button>\n </nav>\n );\n }\n);\n\nPagination.displayName = 'Pagination';\n",
77
+ "type": "registry:ui"
78
+ }
79
+ ]
80
+ }
@@ -0,0 +1,85 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "radio",
4
+ "title": "RadioGroup",
5
+ "description": "Radio selection group with vertical/horizontal layout, per-option inactive, and keyboard navigation.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/radio",
11
+ "consumption": {
12
+ "install": "npm install @compomo/ui @ds-mo/tokens",
13
+ "cssSetup": "Import tokens CSS at your app entry point: import '@ds-mo/tokens/css';\nThis provides all var(--color-*), var(--dimension-*), var(--effect-*), and var(--typography-*) custom properties.\nFor dark mode, set data-theme=\"dark\" on <html>.",
14
+ "import": "import { RadioGroup, RadioItem } from '@compomo/ui';",
15
+ "cssImport": "import '@compomo/ui/css';",
16
+ "typeImport": "import type { RadioGroupProps, RadioItemProps, RadioOption } from '@compomo/ui';",
17
+ "internalDependencies": [
18
+ {
19
+ "component": "Text",
20
+ "note": "Used internally by RadioGroup. No action needed — it's bundled in @compomo/ui."
21
+ }
22
+ ],
23
+ "peerDependencies": {
24
+ "required": [
25
+ "react >=17",
26
+ "react-dom >=17",
27
+ "@ds-mo/tokens >=0.1.0"
28
+ ],
29
+ "optional": []
30
+ }
31
+ },
32
+ "props": {
33
+ "value": {
34
+ "type": "string",
35
+ "required": true
36
+ },
37
+ "onChange": {
38
+ "type": "(value: string) => void",
39
+ "required": true
40
+ },
41
+ "options": {
42
+ "type": "RadioOption[]",
43
+ "required": true
44
+ },
45
+ "direction": {
46
+ "type": "'vertical' | 'horizontal'",
47
+ "default": "'vertical'"
48
+ },
49
+ "inactive": {
50
+ "type": "boolean"
51
+ },
52
+ "aria-label": {
53
+ "type": "string"
54
+ }
55
+ },
56
+ "exports": [
57
+ "RadioGroup",
58
+ "RadioItem"
59
+ ],
60
+ "types": [
61
+ "RadioGroupProps",
62
+ "RadioItemProps",
63
+ "RadioOption"
64
+ ]
65
+ },
66
+ "dependencies": [
67
+ "@compomo/ui",
68
+ "@ds-mo/tokens"
69
+ ],
70
+ "registryDependencies": [
71
+ "text"
72
+ ],
73
+ "files": [
74
+ {
75
+ "path": "src/components/Radio/Radio.module.css",
76
+ "content": ".group {\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-025);\n}\n\n.horizontal {\n flex-direction: row;\n gap: var(--dimension-space-100);\n}\n\n/* ─── Radio item ────────────────────────────────────────────────────────────── */\n\n.radio {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050);\n padding: var(--dimension-space-075);\n height: var(--dimension-size-400);\n box-sizing: border-box;\n cursor: pointer;\n -webkit-user-select: none;\n user-select: none;\n border-radius: var(--dimension-radius-075);\n transition: background-color var(--effect-motion-short-2);\n}\n\n.radio:hover { background-color: var(--color-interaction-hover); }\n.radio:active { background-color: var(--color-interaction-pressed); }\n\n.inactive {\n cursor: not-allowed;\n opacity: 0.5;\n pointer-events: none;\n}\n\n/* ─── Circle indicator ──────────────────────────────────────────────────────── */\n\n.circle {\n width: 20px;\n height: 20px;\n border: 1.5px solid var(--color-foreground-secondary);\n border-radius: var(--dimension-radius-half);\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n transition: all var(--effect-motion-short-2);\n box-sizing: border-box;\n}\n\n.circleChecked {\n border-color: var(--color-foreground-primary);\n}\n\n.dot {\n width: 10px;\n height: 10px;\n border-radius: var(--dimension-radius-half);\n background-color: var(--color-foreground-primary);\n}\n",
77
+ "type": "registry:ui"
78
+ },
79
+ {
80
+ "path": "src/components/Radio/Radio.tsx",
81
+ "content": "import React, { forwardRef } from 'react';\nimport { cn } from '@/utils/cn';\nimport { Text } from '@/components/Text';\nimport styles from './Radio.module.css';\n\nexport interface RadioOption {\n label: string;\n value: string;\n inactive?: boolean;\n}\n\nexport interface RadioGroupProps {\n /** Currently selected value. */\n value: string;\n /** Called when selection changes. */\n onChange: (value: string) => void;\n /** Radio options to render. */\n options: RadioOption[];\n /** Group label for accessibility. */\n 'aria-label'?: string;\n /** Layout direction. */\n direction?: 'vertical' | 'horizontal';\n /** Disables the entire group. */\n inactive?: boolean;\n className?: string;\n}\n\nexport const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(\n (\n {\n value,\n onChange,\n options,\n 'aria-label': ariaLabel,\n direction = 'vertical',\n inactive = false,\n className,\n },\n ref\n ) => (\n <div\n ref={ref}\n role=\"radiogroup\"\n aria-label={ariaLabel}\n className={cn(\n styles.group,\n direction === 'horizontal' && styles.horizontal,\n className\n )}\n >\n {options.map(opt => (\n <RadioItem\n key={opt.value}\n label={opt.label}\n checked={value === opt.value}\n inactive={inactive || opt.inactive}\n onChange={() => onChange(opt.value)}\n />\n ))}\n </div>\n )\n);\n\nRadioGroup.displayName = 'RadioGroup';\n\n/* ─── Individual Radio Item ─────────────────────────────────────────────────── */\n\nexport interface RadioItemProps {\n label: string;\n checked?: boolean;\n onChange?: () => void;\n inactive?: boolean;\n className?: string;\n}\n\nexport const RadioItem = forwardRef<HTMLDivElement, RadioItemProps>(\n ({ label, checked = false, onChange, inactive = false, className }, ref) => {\n const handleClick = () => {\n if (!inactive && !checked) onChange?.();\n };\n\n return (\n <div\n ref={ref}\n role=\"radio\"\n aria-checked={checked}\n tabIndex={inactive ? -1 : 0}\n className={cn(styles.radio, inactive && styles.inactive, className)}\n onClick={handleClick}\n onKeyDown={e => {\n if (e.key === ' ' || e.key === 'Enter') {\n e.preventDefault();\n handleClick();\n }\n }}\n >\n <span className={cn(styles.circle, checked && styles.circleChecked)}>\n {checked && <span className={styles.dot} />}\n </span>\n <Text variant=\"text-body-medium\" as=\"span\" color=\"inherit\">\n {label}\n </Text>\n </div>\n );\n }\n);\n\nRadioItem.displayName = 'RadioItem';\n",
82
+ "type": "registry:ui"
83
+ }
84
+ ]
85
+ }