@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,79 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "accordion",
4
+ "title": "Accordion",
5
+ "description": "Collapsible section list with single or multiple expand mode, animated height transitions, and controlled/uncontrolled state.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/accordion",
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 { Accordion, AccordionItem } from '@compomo/ui';",
15
+ "cssImport": "import '@compomo/ui/css';",
16
+ "typeImport": "import type { AccordionProps, AccordionItemProps, AccordionItemData } from '@compomo/ui';",
17
+ "internalDependencies": [
18
+ {
19
+ "component": "Text",
20
+ "note": "Used internally by Accordion. 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
+ "items": {
34
+ "type": "AccordionItemData[]",
35
+ "required": true
36
+ },
37
+ "multiple": {
38
+ "type": "boolean",
39
+ "default": "false",
40
+ "description": "Allow multiple items open at once."
41
+ },
42
+ "expandedIds": {
43
+ "type": "string[]",
44
+ "description": "Controlled expanded state."
45
+ },
46
+ "onExpandedChange": {
47
+ "type": "(ids: string[]) => void"
48
+ }
49
+ },
50
+ "exports": [
51
+ "Accordion",
52
+ "AccordionItem"
53
+ ],
54
+ "types": [
55
+ "AccordionProps",
56
+ "AccordionItemProps",
57
+ "AccordionItemData"
58
+ ]
59
+ },
60
+ "dependencies": [
61
+ "@compomo/ui",
62
+ "@ds-mo/tokens"
63
+ ],
64
+ "registryDependencies": [
65
+ "text"
66
+ ],
67
+ "files": [
68
+ {
69
+ "path": "src/components/Accordion/Accordion.module.css",
70
+ "content": ".accordion {\n display: flex;\n flex-direction: column;\n}\n\n/* ─── Item ──────────────────────────────────────────────────────────────────── */\n\n.item {\n border-bottom: 1px solid var(--color-border-secondary);\n}\n\n.item:first-child {\n border-top: 1px solid var(--color-border-secondary);\n}\n\n.inactive {\n opacity: 0.5;\n pointer-events: none;\n}\n\n/* ─── Trigger ───────────────────────────────────────────────────────────────── */\n\n.trigger {\n display: flex;\n align-items: center;\n justify-content: space-between;\n width: 100%;\n padding: var(--dimension-space-100) var(--dimension-space-050);\n background: none;\n border: none;\n cursor: pointer;\n color: var(--color-foreground-primary);\n border-radius: var(--dimension-radius-050);\n transition: background-color var(--effect-motion-short-2);\n -webkit-user-select: none;\n user-select: none;\n}\n\n.trigger:hover { background-color: var(--color-interaction-hover); }\n.trigger:active { background-color: var(--color-interaction-pressed); }\n\n/* ─── Chevron ───────────────────────────────────────────────────────────────── */\n\n.chevron {\n font-size: 18px;\n line-height: 1;\n transform: rotate(0deg);\n transition: transform var(--effect-motion-short-3);\n color: var(--color-foreground-secondary);\n}\n\n.chevronOpen {\n transform: rotate(90deg);\n}\n\n/* ─── Body (animated height) ────────────────────────────────────────────────── */\n\n.body {\n overflow: hidden;\n transition: height var(--effect-motion-short-3);\n}\n\n.bodyInner {\n padding: var(--dimension-space-050) var(--dimension-space-050) var(--dimension-space-100);\n color: var(--color-foreground-secondary);\n}\n",
71
+ "type": "registry:ui"
72
+ },
73
+ {
74
+ "path": "src/components/Accordion/Accordion.tsx",
75
+ "content": "import React, { forwardRef, useState, useRef, useEffect, useCallback } from 'react';\nimport { cn } from '@/utils/cn';\nimport { Text } from '@/components/Text';\nimport styles from './Accordion.module.css';\n\nexport interface AccordionItemData {\n id: string;\n label: string;\n content: React.ReactNode;\n inactive?: boolean;\n}\n\nexport interface AccordionProps {\n /** Items to render. */\n items: AccordionItemData[];\n /** Allow multiple items open at once. When false, behaves as an accordion. */\n multiple?: boolean;\n /** Controlled: which item IDs are expanded. */\n expandedIds?: string[];\n /** Called when expansion state changes. */\n onExpandedChange?: (ids: string[]) => void;\n className?: string;\n}\n\nexport const Accordion = forwardRef<HTMLDivElement, AccordionProps>(\n ({ items, multiple = false, expandedIds: controlledIds, onExpandedChange, className }, ref) => {\n const [internalIds, setInternalIds] = useState<string[]>([]);\n const expandedIds = controlledIds ?? internalIds;\n\n const toggle = (id: string) => {\n let next: string[];\n if (expandedIds.includes(id)) {\n next = expandedIds.filter(x => x !== id);\n } else {\n next = multiple ? [...expandedIds, id] : [id];\n }\n setInternalIds(next);\n onExpandedChange?.(next);\n };\n\n return (\n <div ref={ref} className={cn(styles.accordion, className)}>\n {items.map(item => (\n <AccordionItem\n key={item.id}\n label={item.label}\n isExpanded={expandedIds.includes(item.id)}\n inactive={item.inactive}\n onToggle={() => toggle(item.id)}\n >\n {item.content}\n </AccordionItem>\n ))}\n </div>\n );\n }\n);\n\nAccordion.displayName = 'Accordion';\n\n/* ─── AccordionItem ─────────────────────────────────────────────────────────── */\n\nexport interface AccordionItemProps {\n label: string;\n children: React.ReactNode;\n isExpanded?: boolean;\n onToggle?: () => void;\n inactive?: boolean;\n className?: string;\n}\n\nexport const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(\n ({ label, children, isExpanded = false, onToggle, inactive = false, className }, ref) => {\n const contentRef = useRef<HTMLDivElement>(null);\n const [height, setHeight] = useState<number>(0);\n\n const measure = useCallback(() => {\n if (contentRef.current) {\n setHeight(contentRef.current.scrollHeight);\n }\n }, []);\n\n useEffect(() => {\n measure();\n const el = contentRef.current;\n if (!el) return;\n const ro = new ResizeObserver(measure);\n ro.observe(el);\n return () => ro.disconnect();\n }, [measure]);\n\n return (\n <div ref={ref} className={cn(styles.item, inactive && styles.inactive, className)}>\n <button\n type=\"button\"\n className={styles.trigger}\n onClick={() => !inactive && onToggle?.()}\n aria-expanded={isExpanded}\n tabIndex={inactive ? -1 : 0}\n >\n <Text variant=\"text-body-medium-emphasis\" as=\"span\" color=\"inherit\">\n {label}\n </Text>\n <span className={cn(styles.chevron, isExpanded && styles.chevronOpen)}>\n &#x203A;\n </span>\n </button>\n <div\n className={styles.body}\n style={{ height: isExpanded ? height : 0 }}\n aria-hidden={!isExpanded}\n >\n <div ref={contentRef} className={styles.bodyInner}>\n {children}\n </div>\n </div>\n </div>\n );\n }\n);\n\nAccordionItem.displayName = 'AccordionItem';\n",
76
+ "type": "registry:ui"
77
+ }
78
+ ]
79
+ }
@@ -0,0 +1,69 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "badge",
4
+ "title": "Badge",
5
+ "description": "Compact count indicator with selection state.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/badge",
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 { Badge } from '@compomo/ui';",
15
+ "cssImport": "import '@compomo/ui/css';",
16
+ "typeImport": "import type { BadgeProps } from '@compomo/ui';",
17
+ "internalDependencies": [
18
+ {
19
+ "component": "Text",
20
+ "note": "Used internally by Badge. 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
+ "count": {
34
+ "type": "number",
35
+ "required": true,
36
+ "description": "Returns null if 0. Shows '+' if > 9."
37
+ },
38
+ "isSelected": {
39
+ "type": "boolean",
40
+ "default": "false"
41
+ }
42
+ },
43
+ "exports": [
44
+ "Badge"
45
+ ],
46
+ "types": [
47
+ "BadgeProps"
48
+ ]
49
+ },
50
+ "dependencies": [
51
+ "@compomo/ui",
52
+ "@ds-mo/tokens"
53
+ ],
54
+ "registryDependencies": [
55
+ "text"
56
+ ],
57
+ "files": [
58
+ {
59
+ "path": "src/components/Badge/Badge.module.css",
60
+ "content": ".badge {\n display: flex;\n align-items: center;\n justify-content: center;\n pointer-events: none;\n}\n",
61
+ "type": "registry:ui"
62
+ },
63
+ {
64
+ "path": "src/components/Badge/Badge.tsx",
65
+ "content": "import React from 'react';\nimport { Text } from '@/components/Text';\nimport styles from './Badge.module.css';\n\nexport interface BadgeProps {\n count: number;\n isSelected?: boolean;\n className?: string;\n}\n\nexport const Badge: React.FC<BadgeProps> = ({ count, isSelected = false, className }) => {\n if (count === 0) return null;\n const displayText = count > 9 ? '+' : count.toString();\n return (\n <span className={`${styles.badge} ${className || ''}`} aria-label={`${count} unread`}>\n <Text variant=\"text-caption-emphasis\" as=\"span\" color={isSelected ? 'primary' : 'secondary'}>\n {displayText}\n </Text>\n </span>\n );\n};\n\nBadge.displayName = 'Badge';\n",
66
+ "type": "registry:ui"
67
+ }
68
+ ]
69
+ }
@@ -0,0 +1,90 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "banner",
4
+ "title": "Banner",
5
+ "description": "Intent-colored message bar. Supports floating toast mode with auto-dismiss and fade-out animation.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/banner",
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 { Banner } from '@compomo/ui';",
15
+ "cssImport": "import '@compomo/ui/css';",
16
+ "typeImport": "import type { BannerProps, BannerIntent, BannerContrast } from '@compomo/ui';",
17
+ "internalDependencies": [
18
+ {
19
+ "component": "Surface",
20
+ "note": "Used internally by Banner. No action needed — it's bundled in @compomo/ui."
21
+ },
22
+ {
23
+ "component": "Text",
24
+ "note": "Used internally by Banner. 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
+ "intent": {
38
+ "type": "'brand' | 'positive' | 'negative' | 'warning' | 'caution' | 'ai' | 'neutral' | 'walkthrough' | 'guide'",
39
+ "required": true
40
+ },
41
+ "contrast": {
42
+ "type": "'faint' | 'medium' | 'bold' | 'strong'",
43
+ "required": true
44
+ },
45
+ "message": {
46
+ "type": "string",
47
+ "required": true
48
+ },
49
+ "floating": {
50
+ "type": "boolean",
51
+ "description": "Renders as portal toast with auto-dismiss."
52
+ },
53
+ "onDismiss": {
54
+ "type": "() => void"
55
+ },
56
+ "header": {
57
+ "type": "boolean",
58
+ "description": "Shows decorative header."
59
+ }
60
+ },
61
+ "exports": [
62
+ "Banner"
63
+ ],
64
+ "types": [
65
+ "BannerProps",
66
+ "BannerIntent",
67
+ "BannerContrast"
68
+ ]
69
+ },
70
+ "dependencies": [
71
+ "@compomo/ui",
72
+ "@ds-mo/tokens"
73
+ ],
74
+ "registryDependencies": [
75
+ "surface",
76
+ "text"
77
+ ],
78
+ "files": [
79
+ {
80
+ "path": "src/components/Banner/Banner.module.css",
81
+ "content": ".bannerWrapper {}\n\n.banner {\n position: relative;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n}\n\n.bannerHeaderSurface {\n width: 100%;\n height: var(--dimension-space-100);\n flex-shrink: 0;\n overflow: hidden;\n border-bottom: var(--dimension-stroke-width-015) solid var(--color-border-primary);\n}\n\n.bannerHeader {\n width: 100%;\n height: 100%;\n background: repeating-linear-gradient(\n -45deg,\n var(--banner-content),\n var(--banner-content) var(--dimension-size-100),\n transparent var(--dimension-size-100),\n transparent var(--dimension-size-200)\n );\n}\n\n.bannerBody {\n padding: var(--dimension-space-075);\n min-height: var(--dimension-size-400);\n}\n\n.message {\n color: var(--banner-content);\n line-height: var(--typography-lineheight-md);\n display: block;\n}\n\n.floatingPosition {\n position: fixed;\n top: var(--dimension-space-200);\n left: 50%;\n transform: translateX(-50%);\n pointer-events: none;\n}\n\n.floatingToast {\n backface-visibility: hidden;\n -webkit-font-smoothing: subpixel-antialiased;\n animation: toastFadeIn var(--effect-motion-short-2) forwards;\n}\n\n.floatingToast.closing {\n animation: toastFadeOut var(--effect-motion-short-3) forwards;\n}\n\n@keyframes toastFadeIn {\n from { opacity: 0; transform: translateY(-8px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n@keyframes toastFadeOut {\n from { opacity: 1; transform: translateY(0); }\n to { opacity: 0; transform: translateY(-4px); }\n}\n",
82
+ "type": "registry:ui"
83
+ },
84
+ {
85
+ "path": "src/components/Banner/Banner.tsx",
86
+ "content": "import React, { useState, useEffect, useCallback, useRef } from 'react';\nimport { createPortal } from 'react-dom';\nimport { Surface } from '@/components/Surface';\nimport { Text } from '@/components/Text';\nimport { getCssTimeMs } from '@/utils/css-tokens';\nimport styles from './Banner.module.css';\n\nconst TOAST_DURATION_FALLBACK_MS = 4000;\nconst FADE_OUT_DURATION_MS = 200;\n\nexport type BannerIntent =\n | 'brand' | 'positive' | 'negative' | 'warning' | 'caution'\n | 'ai' | 'neutral' | 'walkthrough' | 'guide';\n\nexport type BannerContrast = 'faint' | 'medium' | 'bold' | 'strong';\n\nexport interface BannerProps {\n intent: BannerIntent;\n contrast: BannerContrast;\n message: string;\n header?: boolean;\n className?: string;\n floating?: boolean;\n onDismiss?: () => void;\n}\n\nconst getContentColorVar = (intent: BannerIntent, contrast: BannerContrast): string => {\n if (contrast === 'bold') return 'var(--color-foreground-on-bold-background-primary)';\n const contentContrast = contrast === 'faint' ? 'bold' : contrast === 'medium' ? 'strong' : 'medium';\n return `var(--color-foreground-${contentContrast}-${intent})`;\n};\n\nexport const Banner: React.FC<BannerProps> = ({\n intent,\n contrast,\n message,\n header = false,\n className = '',\n floating = false,\n onDismiss,\n}) => {\n const toastDurationMs = getCssTimeMs('--effect-animation-delay-long-2', TOAST_DURATION_FALLBACK_MS);\n const messageColor = getContentColorVar(intent, contrast);\n const [isClosing, setIsClosing] = useState(false);\n const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const scheduleDismiss = useCallback(() => {\n if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);\n if (closeTimerRef.current) clearTimeout(closeTimerRef.current);\n dismissTimerRef.current = setTimeout(() => {\n dismissTimerRef.current = null;\n setIsClosing(true);\n closeTimerRef.current = setTimeout(() => {\n closeTimerRef.current = null;\n onDismiss?.();\n }, FADE_OUT_DURATION_MS);\n }, toastDurationMs);\n }, [onDismiss, toastDurationMs]);\n\n useEffect(() => {\n if (floating && message) scheduleDismiss();\n return () => {\n if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);\n if (closeTimerRef.current) clearTimeout(closeTimerRef.current);\n };\n }, [floating, message, scheduleDismiss]);\n\n const content = (\n <div\n className={`${styles.bannerWrapper} ${className}`.trim()}\n style={{ '--banner-content': messageColor } as React.CSSProperties}\n >\n <Surface\n elevation={floating ? 'floating' : 'elevated'}\n intent={intent}\n contrast={contrast}\n radius=\"sm\"\n className={styles.banner}\n >\n {header && (\n <div className={styles.bannerHeaderSurface}>\n <div className={styles.bannerHeader} aria-hidden />\n </div>\n )}\n <div className={styles.bannerBody}>\n <Text variant=\"text-body-medium\" as=\"span\" className={styles.message}>\n {message}\n </Text>\n </div>\n </Surface>\n </div>\n );\n\n if (floating) {\n return createPortal(\n <div className={styles.floatingPosition} style={{ zIndex: 9998 }}>\n <div className={`${styles.floatingToast} ${isClosing ? styles.closing : ''}`}>\n {content}\n </div>\n </div>,\n document.body\n );\n }\n\n return content;\n};\n\nBanner.displayName = 'Banner';\n",
87
+ "type": "registry:ui"
88
+ }
89
+ ]
90
+ }
@@ -0,0 +1,70 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "breadcrumb",
4
+ "title": "Breadcrumb",
5
+ "description": "Hierarchical navigation with link or button items, custom separator, and aria-current on the last item.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/breadcrumb",
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 { Breadcrumb } from '@compomo/ui';",
15
+ "cssImport": "import '@compomo/ui/css';",
16
+ "typeImport": "import type { BreadcrumbProps, BreadcrumbItem } from '@compomo/ui';",
17
+ "internalDependencies": [
18
+ {
19
+ "component": "Text",
20
+ "note": "Used internally by Breadcrumb. 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
+ "items": {
34
+ "type": "BreadcrumbItem[]",
35
+ "required": true,
36
+ "description": "Last item is treated as current page."
37
+ },
38
+ "separator": {
39
+ "type": "React.ReactNode",
40
+ "default": "'/'"
41
+ }
42
+ },
43
+ "exports": [
44
+ "Breadcrumb"
45
+ ],
46
+ "types": [
47
+ "BreadcrumbProps",
48
+ "BreadcrumbItem"
49
+ ]
50
+ },
51
+ "dependencies": [
52
+ "@compomo/ui",
53
+ "@ds-mo/tokens"
54
+ ],
55
+ "registryDependencies": [
56
+ "text"
57
+ ],
58
+ "files": [
59
+ {
60
+ "path": "src/components/Breadcrumb/Breadcrumb.module.css",
61
+ "content": ".breadcrumb {\n display: flex;\n align-items: center;\n}\n\n.list {\n display: flex;\n align-items: center;\n list-style: none;\n margin: 0;\n padding: 0;\n gap: var(--dimension-space-050);\n}\n\n.item {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050);\n}\n\n.link {\n color: var(--color-foreground-secondary);\n text-decoration: none;\n background: none;\n border: none;\n padding: var(--dimension-space-025) var(--dimension-space-050);\n border-radius: var(--dimension-radius-050);\n cursor: pointer;\n transition: background-color var(--effect-motion-short-2), color var(--effect-motion-short-2);\n}\n\n.link:hover {\n background-color: var(--color-interaction-hover);\n color: var(--color-foreground-primary);\n}\n\n.link:active {\n background-color: var(--color-interaction-pressed);\n}\n\n.separator {\n color: var(--color-foreground-tertiary);\n font-size: 12px;\n -webkit-user-select: none;\n user-select: none;\n}\n",
62
+ "type": "registry:ui"
63
+ },
64
+ {
65
+ "path": "src/components/Breadcrumb/Breadcrumb.tsx",
66
+ "content": "import React, { forwardRef } from 'react';\nimport { cn } from '@/utils/cn';\nimport { Text } from '@/components/Text';\nimport styles from './Breadcrumb.module.css';\n\nexport interface BreadcrumbItem {\n label: string;\n href?: string;\n onClick?: () => void;\n}\n\nexport interface BreadcrumbProps {\n /** Ordered list of breadcrumb items. Last item is treated as current page. */\n items: BreadcrumbItem[];\n /** Custom separator. Defaults to '/'. */\n separator?: React.ReactNode;\n className?: string;\n}\n\nexport const Breadcrumb = forwardRef<HTMLElement, BreadcrumbProps>(\n ({ items, separator = '/', className }, ref) => (\n <nav ref={ref} aria-label=\"Breadcrumb\" className={cn(styles.breadcrumb, className)}>\n <ol className={styles.list}>\n {items.map((item, i) => {\n const isLast = i === items.length - 1;\n return (\n <li key={i} className={styles.item}>\n {isLast ? (\n <Text variant=\"text-body-medium-emphasis\" as=\"span\" color=\"primary\" aria-current=\"page\">\n {item.label}\n </Text>\n ) : (\n <>\n {item.href ? (\n <a\n href={item.href}\n className={styles.link}\n onClick={e => { if (item.onClick) { e.preventDefault(); item.onClick(); } }}\n >\n <Text variant=\"text-body-medium\" as=\"span\" color=\"inherit\">\n {item.label}\n </Text>\n </a>\n ) : (\n <button\n type=\"button\"\n className={styles.link}\n onClick={item.onClick}\n >\n <Text variant=\"text-body-medium\" as=\"span\" color=\"inherit\">\n {item.label}\n </Text>\n </button>\n )}\n <span className={styles.separator} aria-hidden=\"true\">\n {separator}\n </span>\n </>\n )}\n </li>\n );\n })}\n </ol>\n </nav>\n )\n);\n\nBreadcrumb.displayName = 'Breadcrumb';\n",
67
+ "type": "registry:ui"
68
+ }
69
+ ]
70
+ }
@@ -0,0 +1,65 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "button-group",
4
+ "title": "ButtonGroup",
5
+ "description": "Groups Button children with automatic dividers and shared elevation/rounding. Reads props from first child to drive group styling.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/button-group",
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 { ButtonGroup } from '@compomo/ui';",
15
+ "cssImport": "import '@compomo/ui/css';",
16
+ "typeImport": "import type { ButtonGroupProps } from '@compomo/ui';",
17
+ "internalDependencies": [
18
+ {
19
+ "component": "Button",
20
+ "note": "Used internally by ButtonGroup. 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
+ "children": {
34
+ "type": "React.ReactNode",
35
+ "required": true,
36
+ "description": "Button components as children."
37
+ }
38
+ },
39
+ "exports": [
40
+ "ButtonGroup"
41
+ ],
42
+ "types": [
43
+ "ButtonGroupProps"
44
+ ]
45
+ },
46
+ "dependencies": [
47
+ "@compomo/ui",
48
+ "@ds-mo/tokens"
49
+ ],
50
+ "registryDependencies": [
51
+ "button"
52
+ ],
53
+ "files": [
54
+ {
55
+ "path": "src/components/ButtonGroup/ButtonGroup.module.css",
56
+ "content": "/* ==========================================================================\n ButtonGroup — flush row of Button components\n ========================================================================== */\n\n.group {\n display: inline-flex;\n align-items: stretch;\n border-radius: var(--dimension-radius-050);\n position: relative;\n}\n\n/* Inset ring / highlight overlay — sits above button children so it isn't\n covered by their backgrounds. Outset shadow stays on the element itself. */\n.group::after {\n content: '';\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n}\n\n/* ── Group-level elevation ──────────────────────────────────────────────── */\n/* Individual item chrome is suppressed via .item — the group container\n carries the elevation so it wraps the whole control as one visual unit. */\n\n/* Outset shadow on element; inset highlight on ::after (children would cover it otherwise) */\n.groupElevated { box-shadow: var(--effect-shadow-elevated-sm); }\n.groupElevated::after { box-shadow: var(--effect-highlight-elevated-sm); }\n\n/* Flat ring lives entirely on ::after — same reasoning as highlight */\n.groupFlat::after { box-shadow: inset 0 0 0 1px var(--color-border-tertiary); }\n\n.groupFloating { box-shadow: var(--effect-shadow-elevated-floating); }\n.groupFloating::after { box-shadow: var(--effect-highlight-elevated-floating); }\n/* groupNone = ghost / primary — no shadow, no border */\n\n.groupRounded { border-radius: var(--dimension-radius-half); }\n\n/* ── Base item ─────────────────────────────────────────────────────────────── */\n\n.item {\n flex-shrink: 0;\n /* Suppress individual elevation so the group reads as one unit */\n box-shadow: none !important;\n /* Suppress noElevation individual border — divider handles separation */\n border: none !important;\n /* Suppress scale-on-press: transform would detach the divider from the\n adjacent button, creating a visible gap. Pressed state is still\n communicated via the ::after overlay colour. */\n}\n.item:active {\n transform: none !important;\n}\n\n/* ── Divider ────────────────────────────────────────────────────────────────\n * Rendered as a separate <div> between items so it is never part of any\n * button's stacking context — disabled (opacity: 0.5) or other state changes\n * on adjacent buttons cannot bleed through to the divider.\n * ─────────────────────────────────────────────────────────────────────────── */\n\n/* Full-height divider (secondary + primary).\n * Overlaps each neighbour by 0.5px and paints on top so the alpha token\n * composites over the button fill rather than the page background. */\n.divider {\n width: 1px;\n flex-shrink: 0;\n background-color: var(--color-border-tertiary);\n position: relative;\n z-index: 1;\n margin: 0 -0.5px;\n pointer-events: none;\n}\n\n/* Ghost (elevation=none): short centred divider at icon height */\n.dividerGhost { height: 20px; align-self: center; } /* md default */\n.dividerGhostXS { height: 12px; }\n.dividerGhostSM { height: 16px; }\n.dividerGhostLG { height: 24px; }\n\n/* Primary intent: on-background divider colour */\n.dividerBold { background-color: var(--color-border-on-bold-background-tertiary); }\n.dividerMedium { background-color: var(--color-border-on-medium-background-tertiary); }\n.dividerStrong { background-color: var(--color-border-on-strong-background-tertiary); }\n\n/* Ghost hover-fade: hide the divider on either side of a hovered ghost button */\n.item:hover + .dividerGhost,\n.dividerGhost:has(+ .item:hover) { opacity: 0; }\n\n/* ── Ghost interaction states: rounded chips ──────────────────────────────────\n * Ghost items have no group chrome — hover/press overlay should appear as\n * fully rounded chips (4px all corners). Rounded groups: outer corners of\n * first/last become pill; inner corners stay 4px.\n *\n * Uses .group ancestor (2-class specificity) to reliably beat .first / .last /\n * .middle (1-class) regardless of load order. Explicit longhands throughout to\n * avoid shorthand/longhand cascade ambiguity with the !important rules above.\n * ─────────────────────────────────────────────────────────────────────────── */\n\n/* ::after hover/press overlay — all positions: 4px all corners */\n.group .itemShort::after {\n border-top-left-radius: var(--dimension-radius-050) !important;\n border-top-right-radius: var(--dimension-radius-050) !important;\n border-bottom-right-radius: var(--dimension-radius-050) !important;\n border-bottom-left-radius: var(--dimension-radius-050) !important;\n}\n\n/* ── Border radius: flatten only the inner-facing corners ──────────────────── */\n/* Outer corners keep the button's own radius — works for default + rounded */\n\n.first {\n border-top-right-radius: 0 !important;\n border-bottom-right-radius: 0 !important;\n}\n\n.last {\n border-top-left-radius: 0 !important;\n border-bottom-left-radius: 0 !important;\n}\n\n.middle {\n border-radius: 0 !important;\n}\n\n/* Rounded group: first — left corners become pill (3-class specificity) */\n.groupRounded .first.itemShort::after {\n border-top-left-radius: var(--dimension-radius-half) !important;\n border-bottom-left-radius: var(--dimension-radius-half) !important;\n}\n\n/* Rounded group: last — right corners become pill (3-class specificity) */\n.groupRounded .last.itemShort::after {\n border-top-right-radius: var(--dimension-radius-half) !important;\n border-bottom-right-radius: var(--dimension-radius-half) !important;\n}\n\n/* ── Rounded: inner-edge padding correction ────────────────────────────────── */\n/*\n * Rounded buttons add `space-050` extra padding on each side.\n * In a group the inner-facing edge should use base (non-pill) padding instead.\n * These classes are injected by ButtonGroup for first/last/mid × size.\n * XS rounded has no extra padding so needs no correction.\n */\n\n/* MD — base padding: space-100 */\n.roundedFirstMD { padding-right: var(--dimension-space-100) !important; }\n.roundedLastMD { padding-left: var(--dimension-space-100) !important; }\n.roundedMidMD { padding-left: var(--dimension-space-100) !important; padding-right: var(--dimension-space-100) !important; }\n\n/* SM — base padding: space-075 */\n.roundedFirstSM { padding-right: var(--dimension-space-075) !important; }\n.roundedLastSM { padding-left: var(--dimension-space-075) !important; }\n.roundedMidSM { padding-left: var(--dimension-space-075) !important; padding-right: var(--dimension-space-075) !important; }\n\n/* LG — base padding: space-150 */\n.roundedFirstLG { padding-right: var(--dimension-space-150) !important; }\n.roundedLastLG { padding-left: var(--dimension-space-150) !important; }\n.roundedMidLG { padding-left: var(--dimension-space-150) !important; padding-right: var(--dimension-space-150) !important; }\n",
57
+ "type": "registry:ui"
58
+ },
59
+ {
60
+ "path": "src/components/ButtonGroup/ButtonGroup.tsx",
61
+ "content": "import React from 'react';\nimport { cn } from '@/utils/cn';\nimport styles from './ButtonGroup.module.css';\n\nexport interface ButtonGroupProps {\n children: React.ReactNode;\n className?: string;\n}\n\n/**\n * Renders Button children flush side-by-side with a 1px divider between them.\n * Outer corners keep the button's own border-radius; inner corners are flattened.\n *\n * Individual button chrome (shadow/border) is suppressed — the group container\n * carries the elevation so it wraps the whole control as one visual unit.\n *\n * Divider behaviour by variant:\n * secondary / primary+none — full-height color-border-tertiary line\n * primary (with intent) — color-border-tertiary-on-{contrast}-background\n * ghost (secondary+none) — short centred line (icon/text height)\n *\n * Rounded groups: outer edges keep pill padding; inner edges revert to base padding.\n * Mix of intents is only valid when variant=\"secondary\"; primary groups must be\n * homogeneous (same intent + contrast on all items).\n */\nexport const ButtonGroup = ({ children, className }: ButtonGroupProps) => {\n const items = React.Children.toArray(children).filter(React.isValidElement);\n const count = items.length;\n\n // Read elevation + rounded from first child to drive the group container style.\n const firstEl = items[0] as React.ReactElement<Record<string, unknown>> | undefined;\n const firstProps = firstEl?.props as { variant?: string; elevation?: string; rounded?: boolean } | undefined;\n const firstVariant = firstProps?.variant ?? 'secondary';\n const firstElevation = firstProps?.elevation ?? (firstVariant === 'primary' ? 'none' : 'flat');\n const groupRounded = !!firstProps?.rounded;\n const groupElevKey = firstElevation.charAt(0).toUpperCase() + firstElevation.slice(1);\n\n return (\n <div\n className={cn(\n styles.group,\n (styles as Record<string, string>)[`group${groupElevKey}`],\n groupRounded && styles.groupRounded,\n className,\n )}\n role=\"group\"\n >\n {items.map((child, i) => {\n const el = child as React.ReactElement<Record<string, unknown>>;\n const p = el.props as {\n className?: string;\n variant?: string;\n elevation?: string;\n size?: string;\n intent?: string;\n contrast?: string;\n rounded?: boolean;\n icon?: unknown;\n label?: unknown;\n };\n\n const isFirst = i === 0;\n const isLast = i === count - 1;\n const isMid = !isFirst && !isLast;\n\n const variant = p.variant ?? 'secondary';\n const size = (p.size ?? 'md') as 'xs' | 'sm' | 'md' | 'lg';\n const contrast = p.contrast ?? 'bold';\n const intent = p.intent ?? 'brand';\n const isRounded = !!p.rounded;\n const isIconOnly = !!p.icon && !p.label;\n // Ghost: secondary button with elevation='none' (replaces old tertiary variant)\n const isGhost = variant === 'secondary' && (p.elevation ?? 'flat') === 'none';\n const isPrimary = variant === 'primary' && intent !== 'none';\n\n // Contrast class suffix — capitalised first letter\n const contrastKey = contrast.charAt(0).toUpperCase() + contrast.slice(1);\n\n // Size suffix for rounded padding correction — uppercase\n // XS rounded has no extra padding in Button so needs no correction\n const sizeKey = size.toUpperCase() as 'MD' | 'SM' | 'LG' | 'XS';\n const needsRoundedCorrection = isRounded && !isIconOnly && size !== 'xs';\n\n // Divider injected as a separate element so it's never affected by\n // the adjacent button's opacity (e.g. disabled state).\n const dividerClasses = cn(\n styles.divider,\n isGhost && styles.dividerGhost,\n isGhost && size === 'xs' && styles.dividerGhostXS,\n isGhost && size === 'sm' && styles.dividerGhostSM,\n isGhost && size === 'lg' && styles.dividerGhostLG,\n isPrimary && (styles as Record<string, string>)[`divider${contrastKey}`],\n );\n\n return (\n <React.Fragment key={i}>\n {i > 0 && <div className={dividerClasses} aria-hidden=\"true\" />}\n {React.cloneElement(el, {\n className: cn(\n p.className,\n styles.item,\n isFirst && styles.first,\n isLast && styles.last,\n isMid && styles.middle,\n\n // Ghost (secondary+elevation=none): ::after overlay gets 4px radius treatment\n isGhost && styles.itemShort,\n\n // Rounded: revert inner-edge padding to base (non-pill) value\n needsRoundedCorrection && isFirst && (styles as Record<string, string>)[`roundedFirst${sizeKey}`],\n needsRoundedCorrection && isLast && (styles as Record<string, string>)[`roundedLast${sizeKey}`],\n needsRoundedCorrection && isMid && (styles as Record<string, string>)[`roundedMid${sizeKey}`],\n ),\n })}\n </React.Fragment>\n );\n })}\n </div>\n );\n};\n\nButtonGroup.displayName = 'ButtonGroup';\n",
62
+ "type": "registry:ui"
63
+ }
64
+ ]
65
+ }
@@ -0,0 +1,137 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "button",
4
+ "title": "Button",
5
+ "description": "Primary action component with variants (primary/secondary), 8 semantic intents, 4 sizes, icon support, loading state, dropdown indicator, badge count, and elevation levels.",
6
+ "type": "registry:ui",
7
+ "meta": {
8
+ "library": "@compomo/ui",
9
+ "distribution": "npm",
10
+ "storybook": "https://zainadeel.github.io/CompoMo/?path=/story/button",
11
+ "consumption": {
12
+ "install": "npm install @compomo/ui @ds-mo/tokens @ds-mo/icons",
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 { Button } from '@compomo/ui';",
15
+ "cssImport": "import '@compomo/ui/css';",
16
+ "typeImport": "import type { ButtonProps, ButtonVariant, ButtonElevation, ButtonIntent, ButtonSize, ButtonContrast } from '@compomo/ui';",
17
+ "iconImport": "import { IconName } from '@ds-mo/icons'; // Tree-shakeable, import only what you need",
18
+ "internalDependencies": [
19
+ {
20
+ "component": "Text",
21
+ "note": "Used internally by Button. No action needed — it's bundled in @compomo/ui."
22
+ },
23
+ {
24
+ "component": "Loader",
25
+ "note": "Used internally by Button. No action needed — it's bundled in @compomo/ui."
26
+ },
27
+ {
28
+ "component": "LabelWrap",
29
+ "note": "Used internally by Button. No action needed — it's bundled in @compomo/ui."
30
+ }
31
+ ],
32
+ "peerDependencies": {
33
+ "required": [
34
+ "react >=17",
35
+ "react-dom >=17",
36
+ "@ds-mo/tokens >=0.1.0"
37
+ ],
38
+ "optional": [
39
+ "@ds-mo/icons >=0.1.0 (for icon props)"
40
+ ]
41
+ }
42
+ },
43
+ "props": {
44
+ "label": {
45
+ "type": "string",
46
+ "description": "Button text. Omit for icon-only button."
47
+ },
48
+ "icon": {
49
+ "type": "IconComponent",
50
+ "description": "Leading icon from @ds-mo/icons."
51
+ },
52
+ "variant": {
53
+ "type": "'primary' | 'secondary'",
54
+ "default": "'primary'"
55
+ },
56
+ "intent": {
57
+ "type": "'none' | 'neutral' | 'brand' | 'ai' | 'negative' | 'warning' | 'caution' | 'positive'",
58
+ "default": "'brand'"
59
+ },
60
+ "size": {
61
+ "type": "'xs' | 'sm' | 'md' | 'lg'",
62
+ "default": "'md'"
63
+ },
64
+ "contrast": {
65
+ "type": "'strong' | 'bold' | 'medium' | 'faint'",
66
+ "default": "'bold'"
67
+ },
68
+ "elevation": {
69
+ "type": "'none' | 'flat' | 'elevated' | 'floating'",
70
+ "description": "Defaults to 'none' for primary, 'flat' for secondary."
71
+ },
72
+ "loading": {
73
+ "type": "boolean",
74
+ "description": "Shows spinner, prevents interaction."
75
+ },
76
+ "dropdown": {
77
+ "type": "boolean",
78
+ "description": "Shows trailing ChevronDown icon."
79
+ },
80
+ "badgeCount": {
81
+ "type": "number",
82
+ "description": "Displays badge. Shows '+' if > 9."
83
+ },
84
+ "rounded": {
85
+ "type": "boolean",
86
+ "description": "Pill shape with extra padding."
87
+ },
88
+ "fullWidth": {
89
+ "type": "boolean"
90
+ },
91
+ "inactive": {
92
+ "type": "boolean"
93
+ },
94
+ "as": {
95
+ "type": "React.ElementType",
96
+ "default": "'button'",
97
+ "description": "Polymorphic element."
98
+ },
99
+ "onClick": {
100
+ "type": "(e: React.MouseEvent<HTMLElement>) => void"
101
+ }
102
+ },
103
+ "exports": [
104
+ "Button"
105
+ ],
106
+ "types": [
107
+ "ButtonProps",
108
+ "ButtonVariant",
109
+ "ButtonElevation",
110
+ "ButtonIntent",
111
+ "ButtonSize",
112
+ "ButtonContrast"
113
+ ]
114
+ },
115
+ "dependencies": [
116
+ "@compomo/ui",
117
+ "@ds-mo/tokens",
118
+ "@ds-mo/icons"
119
+ ],
120
+ "registryDependencies": [
121
+ "text",
122
+ "loader",
123
+ "label-wrap"
124
+ ],
125
+ "files": [
126
+ {
127
+ "path": "src/components/Button/Button.module.css",
128
+ "content": "/* ==========================================================================\n Button — unified primary/secondary\n Hover/pressed via color-mix() on background-color. No pseudo-element overlays.\n ========================================================================== */\n\n.button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n border-radius: var(--dimension-radius-050);\n cursor: pointer;\n position: relative;\n transition: transform var(--effect-motion-short-2);\n height: var(--dimension-size-400);\n font-family: inherit;\n color: var(--color-foreground-primary);\n background: transparent;\n box-sizing: border-box;\n text-decoration: none;\n -webkit-user-select: none;\n user-select: none;\n\n /* Interaction — ghost defaults (semi-transparent overlay) */\n --_hover-bg: var(--color-interaction-hover);\n --_pressed-bg: var(--color-interaction-pressed);\n --_border: var(--color-border-tertiary);\n}\n\n.button:focus-visible {\n outline: var(--dimension-stroke-width-025) solid var(--color-interaction-focus);\n outline-offset: var(--dimension-space-025);\n}\n\n.button:hover:not(.inactive) {\n background-color: var(--_hover-bg);\n}\n\n.button:active:not(.inactive) {\n background-color: var(--_pressed-bg);\n transform: scale(var(--dimension-scale-subtle));\n}\n\n/* ==========================================================================\n Background context — parent surface awareness\n Adjusts hover tokens and secondary fg for colored parent surfaces.\n 1-class specificity; overridden by filled variant rules (2+ classes).\n ========================================================================== */\n\n.onMedium {\n --_hover-bg: var(--color-interaction-on-medium-background-hover);\n --_pressed-bg: var(--color-interaction-on-medium-background-pressed);\n --_border: var(--color-border-on-medium-background-tertiary);\n}\n\n.onBold {\n --_hover-bg: var(--color-interaction-on-bold-background-hover);\n --_pressed-bg: var(--color-interaction-on-bold-background-pressed);\n --_border: var(--color-border-on-bold-background-tertiary);\n}\n\n.onStrong {\n --_hover-bg: var(--color-interaction-on-strong-background-hover);\n --_pressed-bg: var(--color-interaction-on-strong-background-pressed);\n --_border: var(--color-border-on-strong-background-tertiary);\n}\n\n.onAlwaysDark {\n --_hover-bg: var(--color-always-dark-interaction-hover);\n --_pressed-bg: var(--color-always-dark-interaction-pressed);\n --_border: var(--color-always-dark-border-tertiary);\n}\n\n/* ==========================================================================\n Variant: Primary\n Bold background, on-bold content. Elevation controls shadow.\n ========================================================================== */\n\n.primary {\n color: var(--color-foreground-on-bold-background-primary);\n}\n\n/* Primary intent: none — inverted surface (black in light mode, white in dark mode) */\n.primary.intentNone {\n background-color: var(--color-inverted-background);\n color: var(--color-inverted-foreground-primary);\n --_hover-bg: color-mix(in oklch, var(--color-inverted-background) 90%, var(--color-inverted-foreground-primary));\n --_pressed-bg: color-mix(in oklch, var(--color-inverted-background) 85%, var(--color-inverted-foreground-primary));\n --_border: var(--color-inverted-border-tertiary);\n}\n\n/* Filled primary — background from --_bg, bold-contrast hover via color-mix.\n 2-class specificity beats background context (1-class). */\n.primary:not(.intentNone) {\n background-color: var(--_bg);\n --_hover-bg: color-mix(in oklch, var(--_bg) 85%, var(--color-foreground-on-bold-background-primary));\n --_pressed-bg: color-mix(in oklch, var(--_bg) 80%, var(--color-foreground-on-bold-background-primary));\n}\n\n/* ── Primary bold contrast (default) ────────────────────────────────── */\n\n.primary.intentBrand { --_bg: var(--color-background-bold-brand); color: var(--color-foreground-faint-brand); }\n.primary.intentNeutral { --_bg: var(--color-background-bold-neutral); color: var(--color-foreground-faint-neutral); }\n.primary.intentPositive { --_bg: var(--color-background-bold-positive); color: var(--color-foreground-faint-positive); }\n.primary.intentNegative { --_bg: var(--color-background-bold-negative); color: var(--color-foreground-faint-negative); }\n.primary.intentWarning { --_bg: var(--color-background-bold-warning); color: var(--color-foreground-faint-warning); }\n.primary.intentCaution { --_bg: var(--color-background-bold-caution); color: var(--color-foreground-faint-caution); }\n.primary.intentAi { --_bg: var(--color-background-bold-ai); color: var(--color-foreground-faint-ai); }\n\n/* ── Primary medium contrast ────────────────────────────────────────── */\n\n.primary.contrastMedium {\n --_hover-bg: color-mix(in oklch, var(--_bg) 85%, var(--color-foreground-primary));\n --_pressed-bg: color-mix(in oklch, var(--_bg) 80%, var(--color-foreground-primary));\n}\n.primary.contrastMedium.intentBrand { --_bg: var(--color-background-medium-brand); color: var(--color-foreground-strong-brand); }\n.primary.contrastMedium.intentNeutral { --_bg: var(--color-background-medium-neutral); color: var(--color-foreground-strong-neutral); }\n.primary.contrastMedium.intentPositive { --_bg: var(--color-background-medium-positive); color: var(--color-foreground-strong-positive); }\n.primary.contrastMedium.intentNegative { --_bg: var(--color-background-medium-negative); color: var(--color-foreground-strong-negative); }\n.primary.contrastMedium.intentWarning { --_bg: var(--color-background-medium-warning); color: var(--color-foreground-strong-warning); }\n.primary.contrastMedium.intentCaution { --_bg: var(--color-background-medium-caution); color: var(--color-foreground-strong-caution); }\n.primary.contrastMedium.intentAi { --_bg: var(--color-background-medium-ai); color: var(--color-foreground-strong-ai); }\n\n/* ── Primary strong contrast ────────────────────────────────────────── */\n\n.primary.contrastStrong {\n --_hover-bg: color-mix(in oklch, var(--_bg) 90%, var(--color-foreground-on-bold-background-primary));\n --_pressed-bg: color-mix(in oklch, var(--_bg) 85%, var(--color-foreground-on-bold-background-primary));\n}\n.primary.contrastStrong.intentBrand { --_bg: var(--color-background-strong-brand); color: var(--color-foreground-medium-brand); }\n.primary.contrastStrong.intentNeutral { --_bg: var(--color-background-strong-neutral); color: var(--color-foreground-medium-neutral); }\n.primary.contrastStrong.intentPositive { --_bg: var(--color-background-strong-positive); color: var(--color-foreground-medium-positive); }\n.primary.contrastStrong.intentNegative { --_bg: var(--color-background-strong-negative); color: var(--color-foreground-medium-negative); }\n.primary.contrastStrong.intentWarning { --_bg: var(--color-background-strong-warning); color: var(--color-foreground-medium-warning); }\n.primary.contrastStrong.intentCaution { --_bg: var(--color-background-strong-caution); color: var(--color-foreground-medium-caution); }\n.primary.contrastStrong.intentAi { --_bg: var(--color-background-strong-ai); color: var(--color-foreground-medium-ai); }\n\n/* ── Primary faint contrast ─────────────────────────────────────────── */\n\n.primary.contrastFaint {\n --_hover-bg: color-mix(in oklch, var(--_bg) 90%, var(--color-foreground-primary));\n --_pressed-bg: color-mix(in oklch, var(--_bg) 85%, var(--color-foreground-primary));\n}\n.primary.contrastFaint.intentBrand { --_bg: var(--color-background-faint-brand); color: var(--color-foreground-bold-brand); }\n.primary.contrastFaint.intentNeutral { --_bg: var(--color-background-faint-neutral); color: var(--color-foreground-bold-neutral); }\n.primary.contrastFaint.intentPositive { --_bg: var(--color-background-faint-positive); color: var(--color-foreground-bold-positive); }\n.primary.contrastFaint.intentNegative { --_bg: var(--color-background-faint-negative); color: var(--color-foreground-bold-negative); }\n.primary.contrastFaint.intentWarning { --_bg: var(--color-background-faint-warning); color: var(--color-foreground-bold-warning); }\n.primary.contrastFaint.intentCaution { --_bg: var(--color-background-faint-caution); color: var(--color-foreground-bold-caution); }\n.primary.contrastFaint.intentAi { --_bg: var(--color-background-faint-ai); color: var(--color-foreground-bold-ai); }\n\n/* ==========================================================================\n Variant: Secondary\n Base is ghost (transparent, no border, no shadow).\n Elevation classes add the chrome — flat=border, elevated=bg+shadow, floating=bg+floating.\n ========================================================================== */\n\n.secondary {\n background-color: transparent;\n color: var(--color-foreground-secondary);\n}\n\n/* Secondary intent — bold fg color */\n.secondary.intentBrand { color: var(--color-foreground-bold-brand); }\n.secondary.intentNeutral { color: var(--color-foreground-bold-neutral); }\n.secondary.intentPositive { color: var(--color-foreground-bold-positive); }\n.secondary.intentNegative { color: var(--color-foreground-bold-negative); }\n.secondary.intentWarning { color: var(--color-foreground-bold-warning); }\n.secondary.intentCaution { color: var(--color-foreground-bold-caution); }\n.secondary.intentAi { color: var(--color-foreground-bold-ai); }\n\n/* ── Secondary fg on medium surface ─────────────────────────────────── */\n\n.secondary.onMedium { color: var(--color-foreground-primary); }\n.secondary.onMedium.intentBrand { color: var(--color-foreground-strong-brand); }\n.secondary.onMedium.intentNeutral { color: var(--color-foreground-strong-neutral); }\n.secondary.onMedium.intentPositive { color: var(--color-foreground-strong-positive); }\n.secondary.onMedium.intentNegative { color: var(--color-foreground-strong-negative); }\n.secondary.onMedium.intentWarning { color: var(--color-foreground-strong-warning); }\n.secondary.onMedium.intentCaution { color: var(--color-foreground-strong-caution); }\n.secondary.onMedium.intentAi { color: var(--color-foreground-strong-ai); }\n\n/* ── Secondary fg on bold surface ──────────────────────────────────── */\n\n.secondary.onBold { color: var(--color-foreground-on-bold-background-secondary); }\n.secondary.onBold.intentBrand { color: var(--color-foreground-faint-brand); }\n.secondary.onBold.intentNeutral { color: var(--color-foreground-faint-neutral); }\n.secondary.onBold.intentPositive { color: var(--color-foreground-faint-positive); }\n.secondary.onBold.intentNegative { color: var(--color-foreground-faint-negative); }\n.secondary.onBold.intentWarning { color: var(--color-foreground-faint-warning); }\n.secondary.onBold.intentCaution { color: var(--color-foreground-faint-caution); }\n.secondary.onBold.intentAi { color: var(--color-foreground-faint-ai); }\n\n/* ── Secondary fg on always-dark surface ──────────────────────────── */\n\n.secondary.onAlwaysDark { color: var(--color-always-dark-foreground-secondary); }\n.secondary.onAlwaysDark.intentBrand { color: var(--color-always-dark-foreground-brand); }\n.secondary.onAlwaysDark.intentNeutral { color: var(--color-always-dark-foreground-neutral); }\n.secondary.onAlwaysDark.intentPositive { color: var(--color-always-dark-foreground-positive); }\n.secondary.onAlwaysDark.intentNegative { color: var(--color-always-dark-foreground-negative); }\n.secondary.onAlwaysDark.intentWarning { color: var(--color-always-dark-foreground-warning); }\n.secondary.onAlwaysDark.intentCaution { color: var(--color-always-dark-foreground-caution); }\n.secondary.onAlwaysDark.intentAi { color: var(--color-always-dark-foreground-ai); }\n\n/* ── Secondary fg on strong surface ─────────────────────────────────── */\n\n.secondary.onStrong { color: var(--color-foreground-on-bold-background-secondary); }\n.secondary.onStrong.intentBrand { color: var(--color-foreground-faint-brand); }\n.secondary.onStrong.intentNeutral { color: var(--color-foreground-faint-neutral); }\n.secondary.onStrong.intentPositive { color: var(--color-foreground-faint-positive); }\n.secondary.onStrong.intentNegative { color: var(--color-foreground-faint-negative); }\n.secondary.onStrong.intentWarning { color: var(--color-foreground-faint-warning); }\n.secondary.onStrong.intentCaution { color: var(--color-foreground-faint-caution); }\n.secondary.onStrong.intentAi { color: var(--color-foreground-faint-ai); }\n\n/* ==========================================================================\n Elevation\n ========================================================================== */\n\n/* ── Secondary chrome ───────────────────────────────────────────────── */\n\n/* elevationNone = ghost base — no extra CSS needed */\n\n.secondary.elevationFlat {\n box-shadow: inset 0 0 0 1px var(--_border);\n}\n\n.secondary.elevationElevated {\n background-color: var(--color-background-primary);\n box-shadow: var(--effect-elevation-elevated-sm);\n --_hover-bg: color-mix(in oklch, var(--color-background-primary) 90%, var(--color-foreground-primary));\n --_pressed-bg: color-mix(in oklch, var(--color-background-primary) 85%, var(--color-foreground-primary));\n}\n\n.secondary.elevationFloating {\n background-color: var(--color-background-primary);\n box-shadow: var(--effect-elevation-elevated-floating);\n --_hover-bg: color-mix(in oklch, var(--color-background-primary) 90%, var(--color-foreground-primary));\n --_pressed-bg: color-mix(in oklch, var(--color-background-primary) 85%, var(--color-foreground-primary));\n}\n\n/* ── Primary shadow + border control ───────────────────────────────── */\n\n/* elevated */\n.primary.elevationElevated { box-shadow: var(--effect-elevation-elevated-sm); }\n\n/* none = no shadow, no border (flat colored button — default for primary) */\n.primary.elevationNone { box-shadow: none; }\n\n/* flat = inset ring against the page background (not the button fill) */\n.primary.elevationFlat { box-shadow: inset 0 0 0 1px var(--_border); }\n\n/* intentNone flat: inverted fill + default border ring (bg/fg inherited from .primary.intentNone) */\n.primary.intentNone.elevationFlat {\n --_border: var(--color-border-tertiary);\n}\n\n/* intentNone on strong/always-dark: flip to surface bg/fg — inverted (black) would be invisible */\n.primary.intentNone.onStrong,\n.primary.intentNone.onAlwaysDark {\n background-color: var(--color-background-primary);\n color: var(--color-foreground-primary);\n --_hover-bg: color-mix(in oklch, var(--color-background-primary) 90%, var(--color-foreground-primary));\n --_pressed-bg: color-mix(in oklch, var(--color-background-primary) 85%, var(--color-foreground-primary));\n --_border: var(--color-border-tertiary);\n}\n\n/* floating = FAB-strength shadow for both variants */\n.primary.elevationFloating { box-shadow: var(--effect-elevation-elevated-floating) !important; }\n\n/* ==========================================================================\n Layout: icon-only, label-only, icon+label, dropdown\n ========================================================================== */\n\n.iconOnly { padding: 0; width: var(--dimension-size-400); min-width: var(--dimension-size-400); }\n.labelOnly { padding: 0 var(--dimension-space-100); }\n.iconAndLabel { padding: 0 var(--dimension-space-100) 0 var(--dimension-space-075); gap: var(--dimension-space-025); }\n.dropdown { gap: var(--dimension-space-050); }\n\n/* ==========================================================================\n Sizes\n ========================================================================== */\n\n.sizeSM { height: var(--dimension-size-300); }\n.sizeSM.iconOnly { width: var(--dimension-size-300); min-width: var(--dimension-size-300); }\n.sizeSM.labelOnly { padding: 0 var(--dimension-space-075); }\n.sizeSM.iconAndLabel { padding: 0 var(--dimension-space-075) 0 var(--dimension-space-050); gap: 1px; }\n\n.sizeXS { height: var(--dimension-size-200); }\n.sizeXS.iconOnly { width: var(--dimension-size-200); min-width: var(--dimension-size-200); }\n.sizeXS.labelOnly, .sizeXS.iconAndLabel { padding: 0 var(--dimension-space-025); gap: 0; }\n\n.sizeLG { height: var(--dimension-size-500); }\n.sizeLG.iconOnly { width: var(--dimension-size-500); min-width: var(--dimension-size-500); }\n.sizeLG.labelOnly { padding: 0 var(--dimension-space-150); }\n.sizeLG.iconAndLabel { padding: 0 var(--dimension-space-150) 0 var(--dimension-space-100); gap: var(--dimension-space-075); }\n\n/* ==========================================================================\n Rounded (pill shape)\n ========================================================================== */\n\n.rounded { border-radius: var(--dimension-radius-half); }\n.rounded.labelOnly { padding-left: calc(var(--dimension-space-100) + var(--dimension-space-050)); padding-right: calc(var(--dimension-space-100) + var(--dimension-space-050)); }\n.rounded.iconAndLabel { padding-left: calc(var(--dimension-space-075) + var(--dimension-space-050)); padding-right: calc(var(--dimension-space-100) + var(--dimension-space-050)); }\n.rounded.sizeSM.labelOnly { padding-left: calc(var(--dimension-space-075) + var(--dimension-space-050)); padding-right: calc(var(--dimension-space-075) + var(--dimension-space-050)); }\n.rounded.sizeSM.iconAndLabel { padding-left: calc(var(--dimension-space-050) + var(--dimension-space-050)); padding-right: calc(var(--dimension-space-075) + var(--dimension-space-050)); }\n.rounded.sizeLG.labelOnly { padding-left: calc(var(--dimension-space-150) + var(--dimension-space-050)); padding-right: calc(var(--dimension-space-150) + var(--dimension-space-050)); }\n.rounded.sizeLG.iconAndLabel { padding-left: calc(var(--dimension-space-100) + var(--dimension-space-050)); padding-right: calc(var(--dimension-space-150) + var(--dimension-space-050)); }\n.rounded.iconOnly { width: var(--dimension-size-400); min-width: var(--dimension-size-400); padding: 0; }\n.rounded.sizeLG.iconOnly { width: var(--dimension-size-500); min-width: var(--dimension-size-500); }\n.rounded.sizeSM.iconOnly { width: var(--dimension-size-300); min-width: var(--dimension-size-300); }\n.rounded.sizeXS.iconOnly { width: var(--dimension-size-200); min-width: var(--dimension-size-200); }\n\n/* ==========================================================================\n Disabled\n ========================================================================== */\n\n.inactive { opacity: 0.5; cursor: not-allowed; pointer-events: none; }\n\n/* ==========================================================================\n Badge\n ========================================================================== */\n\n.badge {\n position: absolute;\n top: var(--dimension-space-025);\n right: var(--dimension-space-025);\n font-size: var(--typography-fontsize-xs);\n font-weight: var(--typography-weight-semibold);\n line-height: var(--typography-lineheight-xs);\n color: var(--color-foreground-secondary);\n pointer-events: none;\n z-index: 10;\n}\n\n/* ==========================================================================\n Full width\n ========================================================================== */\n\n.fullWidth {\n display: flex;\n width: 100%;\n}\n",
129
+ "type": "registry:ui"
130
+ },
131
+ {
132
+ "path": "src/components/Button/Button.tsx",
133
+ "content": "import React, { forwardRef } from 'react';\nimport { cn } from '@/utils/cn';\nimport { Text } from '@/components/Text';\nimport { LabelWrap } from '@/components/LabelWrap';\nimport { Loader } from '@/components/Loader';\nimport { ChevronDown } from '@ds-mo/icons';\nimport type { IconComponent } from '@/types/icons';\nimport styles from './Button.module.css';\n\nexport type ButtonVariant = 'primary' | 'secondary';\nexport type ButtonElevation = 'none' | 'flat' | 'elevated' | 'floating';\nexport type ButtonIntent = 'none' | 'neutral' | 'brand' | 'ai' | 'negative' | 'warning' | 'caution' | 'positive';\nexport type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';\nexport type ButtonContrast = 'strong' | 'bold' | 'medium' | 'faint';\nexport type ButtonBackground = 'faint' | 'medium' | 'bold' | 'strong' | 'always-dark';\n\nexport interface ButtonProps {\n /** Polymorphic element type. Defaults to 'button'. */\n as?: React.ElementType;\n /** Visual variant. Defaults to 'primary'. */\n variant?: ButtonVariant;\n /** Label text. */\n label?: string;\n /** Leading icon component from @ds-mo/icons. */\n icon?: IconComponent;\n /** Semantic intent. Defaults to 'brand'. */\n intent?: ButtonIntent;\n /** Size. Defaults to 'md'. */\n size?: ButtonSize;\n /** Pill shape with extra padding. */\n rounded?: boolean;\n /** Fill container width. Label truncates if space is tight. */\n fullWidth?: boolean;\n /** Fixed width. Omit for content width. */\n width?: React.CSSProperties['width'];\n /** Contrast level (primary variant only). Defaults to 'bold'. */\n contrast?: ButtonContrast;\n /**\n * Parent surface context. Adjusts hover tokens and secondary fg colors\n * for buttons placed on colored backgrounds.\n * faint — default, no adjustment\n * medium — use on-medium-background interaction tokens\n * bold — use on-bold-background interaction + inverted fg\n * strong — use on-strong-background interaction + inverted fg\n * always-dark — dark surface (no theme flip), on-bold-background tokens\n */\n background?: ButtonBackground;\n /** Show trailing ChevronDown dropdown indicator. */\n dropdown?: boolean;\n /** Badge count. */\n badgeCount?: number;\n /**\n * Chrome level for secondary; shadow level for primary.\n * none — ghost (transparent, no border, no shadow)\n * flat — border only, transparent bg [default for secondary]\n * elevated — bg-primary + shadow [default for primary]\n * floating — bg-primary + FAB-strength shadow\n */\n elevation?: ButtonElevation;\n /** Show loading spinner in place of icon. Prevents interaction. */\n loading?: boolean;\n\n onClick?: (e: React.MouseEvent<HTMLElement>) => void;\n onMouseEnter?: React.MouseEventHandler<HTMLElement>;\n onMouseLeave?: React.MouseEventHandler<HTMLElement>;\n className?: string;\n inactive?: boolean;\n type?: 'button' | 'submit' | 'reset';\n id?: string;\n 'aria-label'?: string;\n href?: string;\n target?: string;\n rel?: string;\n}\n\nconst ICON_SIZE_MAP: Record<ButtonSize, number> = { xs: 12, sm: 16, md: 20, lg: 24 };\n\nconst TEXT_STYLE_MAP: Record<ButtonSize, string> = {\n xs: 'text-caption-emphasis',\n sm: 'text-body-small-emphasis',\n md: 'text-body-medium-emphasis',\n lg: 'text-body-large-emphasis',\n};\n\n\nexport const Button = forwardRef<HTMLElement, ButtonProps>(\n (\n {\n as,\n variant = 'primary',\n label,\n icon: Icon,\n intent = 'brand',\n size = 'md',\n rounded = false,\n fullWidth = false,\n width,\n contrast = 'bold',\n background,\n dropdown = false,\n badgeCount,\n elevation,\n loading = false,\n onClick,\n onMouseEnter,\n onMouseLeave,\n className,\n inactive = false,\n type = 'button',\n id,\n 'aria-label': ariaLabel,\n href,\n target,\n rel,\n },\n ref\n ) => {\n const Component = (as ?? 'button') as React.ElementType;\n const isButton = !as || as === 'button';\n const isInteractive = !inactive && !loading;\n\n const hasIcon = !!Icon;\n const hasLabel = !!label;\n const showLeading = hasIcon || loading; // spinner fills the icon slot\n const isIconOnly = showLeading && !hasLabel;\n const isLabelOnly = hasLabel && !showLeading;\n const isIconAndLabel = showLeading && hasLabel;\n const iconSize = ICON_SIZE_MAP[size];\n const textStyle = TEXT_STYLE_MAP[size];\n\n // Derive default elevation per variant: primary→none, secondary→flat\n const effectiveElevation: ButtonElevation = elevation ?? (variant === 'primary' ? 'none' : 'flat');\n const elevKey = effectiveElevation.charAt(0).toUpperCase() + effectiveElevation.slice(1);\n\n const classes = cn(\n styles.button,\n styles[variant],\n styles[`intent${intent.charAt(0).toUpperCase() + intent.slice(1)}`],\n size !== 'md' && styles[`size${size.toUpperCase()}`],\n rounded && styles.rounded,\n (inactive || loading) && styles.inactive,\n isIconOnly && styles.iconOnly,\n isLabelOnly && styles.labelOnly,\n isIconAndLabel && styles.iconAndLabel,\n dropdown && styles.dropdown,\n variant === 'primary' && intent !== 'none' && contrast !== 'bold' && styles[`contrast${contrast.charAt(0).toUpperCase() + contrast.slice(1)}`],\n styles[`elevation${elevKey}`],\n background && background !== 'faint' && styles[\n background === 'always-dark' ? 'onAlwaysDark'\n : `on${background.charAt(0).toUpperCase() + background.slice(1)}`\n ],\n fullWidth && styles.fullWidth,\n className,\n );\n\n const resolvedAriaLabel = ariaLabel || label || 'button';\n\n const extraProps: Record<string, unknown> = {};\n if (isButton) {\n extraProps.type = type;\n extraProps.disabled = inactive || loading;\n }\n if (href) extraProps.href = href;\n if (target) extraProps.target = target;\n if (rel) extraProps.rel = rel;\n\n return (\n <Component\n ref={ref}\n id={id}\n className={classes}\n style={width ? { width } : undefined}\n onClick={isInteractive ? onClick : undefined}\n onMouseEnter={isInteractive ? onMouseEnter : undefined}\n onMouseLeave={isInteractive ? onMouseLeave : undefined}\n aria-label={resolvedAriaLabel}\n aria-busy={loading || undefined}\n {...extraProps}\n >\n {loading ? (\n <Loader size={iconSize} />\n ) : (\n Icon && <Icon size={iconSize} />\n )}\n {label && (\n <LabelWrap truncate={fullWidth}>\n <Text variant={textStyle as never} as=\"span\" color=\"inherit\">\n {label}\n </Text>\n </LabelWrap>\n )}\n {dropdown && <ChevronDown size={iconSize} />}\n {badgeCount != null && badgeCount > 0 && (\n <span className={styles.badge}>{badgeCount > 9 ? '+' : badgeCount}</span>\n )}\n </Component>\n );\n }\n);\n\nButton.displayName = 'Button';\n",
134
+ "type": "registry:ui"
135
+ }
136
+ ]
137
+ }