@pcoi/components 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 (103) hide show
  1. package/dist/components.css +1 -0
  2. package/dist/index.d.ts +667 -0
  3. package/dist/index.js +2 -0
  4. package/dist/index.mjs +1048 -0
  5. package/package.json +36 -0
  6. package/src/Badge/Badge.css +40 -0
  7. package/src/Badge/Badge.tsx +36 -0
  8. package/src/Badge/index.ts +2 -0
  9. package/src/Button/Button.css +93 -0
  10. package/src/Button/Button.figma.tsx +29 -0
  11. package/src/Button/Button.tsx +47 -0
  12. package/src/Button/index.ts +1 -0
  13. package/src/Callout/Callout.css +43 -0
  14. package/src/Callout/Callout.tsx +39 -0
  15. package/src/Callout/index.ts +1 -0
  16. package/src/Card/Card.css +88 -0
  17. package/src/Card/Card.tsx +60 -0
  18. package/src/Card/index.ts +1 -0
  19. package/src/ChatInterface/ChatInterface.css +49 -0
  20. package/src/ChatInterface/ChatInterface.tsx +120 -0
  21. package/src/ChatInterface/index.ts +6 -0
  22. package/src/ChatMessage/ChatMessage.css +55 -0
  23. package/src/ChatMessage/ChatMessage.tsx +71 -0
  24. package/src/ChatMessage/index.ts +2 -0
  25. package/src/ChatMessageList/ChatMessageList.css +24 -0
  26. package/src/ChatMessageList/ChatMessageList.tsx +51 -0
  27. package/src/ChatMessageList/index.ts +2 -0
  28. package/src/Checkbox/Checkbox.css +97 -0
  29. package/src/Checkbox/Checkbox.tsx +70 -0
  30. package/src/Checkbox/index.ts +2 -0
  31. package/src/CitationMark/CitationMark.css +40 -0
  32. package/src/CitationMark/CitationMark.tsx +38 -0
  33. package/src/CitationMark/index.ts +2 -0
  34. package/src/CitedExcerpt/CitedExcerpt.css +75 -0
  35. package/src/CitedExcerpt/CitedExcerpt.tsx +51 -0
  36. package/src/CitedExcerpt/index.ts +2 -0
  37. package/src/ComparisonTable/ComparisonTable.css +66 -0
  38. package/src/ComparisonTable/ComparisonTable.tsx +48 -0
  39. package/src/ComparisonTable/index.ts +1 -0
  40. package/src/ContactForm/ContactForm.css +38 -0
  41. package/src/ContactForm/ContactForm.tsx +57 -0
  42. package/src/ContactForm/index.ts +1 -0
  43. package/src/DataTable/DataTable.css +56 -0
  44. package/src/DataTable/DataTable.tsx +104 -0
  45. package/src/DataTable/index.ts +2 -0
  46. package/src/DocumentOverlay/DocumentOverlay.css +57 -0
  47. package/src/DocumentOverlay/DocumentOverlay.tsx +86 -0
  48. package/src/DocumentOverlay/index.ts +2 -0
  49. package/src/Footer/Footer.css +72 -0
  50. package/src/Footer/Footer.tsx +56 -0
  51. package/src/Footer/index.ts +1 -0
  52. package/src/FormField/FormField.css +78 -0
  53. package/src/FormField/FormField.tsx +103 -0
  54. package/src/FormField/index.ts +2 -0
  55. package/src/HowStep/HowStep.css +48 -0
  56. package/src/HowStep/HowStep.tsx +38 -0
  57. package/src/HowStep/index.ts +1 -0
  58. package/src/LogoMark/LogoMark.css +16 -0
  59. package/src/LogoMark/LogoMark.tsx +25 -0
  60. package/src/LogoMark/index.ts +2 -0
  61. package/src/Modal/Modal.css +101 -0
  62. package/src/Modal/Modal.tsx +141 -0
  63. package/src/Modal/index.ts +2 -0
  64. package/src/Nav/Nav.css +161 -0
  65. package/src/Nav/Nav.tsx +101 -0
  66. package/src/Nav/index.ts +1 -0
  67. package/src/Panel/Panel.css +35 -0
  68. package/src/Panel/Panel.tsx +61 -0
  69. package/src/Panel/index.ts +2 -0
  70. package/src/PromptBar/PromptBar.css +68 -0
  71. package/src/PromptBar/PromptBar.tsx +93 -0
  72. package/src/PromptBar/index.ts +2 -0
  73. package/src/RadioGroup/RadioGroup.css +117 -0
  74. package/src/RadioGroup/RadioGroup.tsx +112 -0
  75. package/src/RadioGroup/index.ts +2 -0
  76. package/src/SectionHeader/SectionHeader.css +38 -0
  77. package/src/SectionHeader/SectionHeader.tsx +55 -0
  78. package/src/SectionHeader/index.ts +1 -0
  79. package/src/Select/Select.css +90 -0
  80. package/src/Select/Select.tsx +100 -0
  81. package/src/Select/index.ts +2 -0
  82. package/src/SignalsPanel/SignalsPanel.css +51 -0
  83. package/src/SignalsPanel/SignalsPanel.tsx +33 -0
  84. package/src/SignalsPanel/index.ts +1 -0
  85. package/src/SuggestionCard/SuggestionCard.css +51 -0
  86. package/src/SuggestionCard/SuggestionCard.tsx +34 -0
  87. package/src/SuggestionCard/index.ts +2 -0
  88. package/src/SuggestionCards/SuggestionCards.css +15 -0
  89. package/src/SuggestionCards/SuggestionCards.tsx +40 -0
  90. package/src/SuggestionCards/index.ts +2 -0
  91. package/src/Toast/Toast.css +85 -0
  92. package/src/Toast/Toast.tsx +77 -0
  93. package/src/Toast/index.ts +2 -0
  94. package/src/Toggle/Toggle.css +110 -0
  95. package/src/Toggle/Toggle.tsx +73 -0
  96. package/src/Toggle/index.ts +2 -0
  97. package/src/TypingIndicator/TypingIndicator.css +70 -0
  98. package/src/TypingIndicator/TypingIndicator.tsx +37 -0
  99. package/src/TypingIndicator/index.ts +2 -0
  100. package/src/index.ts +37 -0
  101. package/src/styles/utilities.css +14 -0
  102. package/src/styles.css +32 -0
  103. package/src/types.ts +65 -0
@@ -0,0 +1,56 @@
1
+ import React from "react";
2
+ import { LogoMark } from "../LogoMark";
3
+ import type { LinkItem } from "../types";
4
+
5
+ /** @deprecated Use `LinkItem` from `@pcoi/components` instead */
6
+ export type FooterLink = LinkItem;
7
+
8
+ export interface FooterProps extends React.HTMLAttributes<HTMLElement> {
9
+ links?: LinkItem[];
10
+ tagline?: string;
11
+ copyright?: string;
12
+ }
13
+
14
+ /**
15
+ * PCOI Footer
16
+ * Tokens: border/default, spacing-60/40/24, text/secondary, text/muted
17
+ */
18
+ export const Footer = React.forwardRef<HTMLElement, FooterProps>(
19
+ (
20
+ {
21
+ links = [
22
+ { label: "Problem", href: "#problem" },
23
+ { label: "How It Works", href: "#how-it-works" },
24
+ { label: "Difference", href: "#difference" },
25
+ { label: "Principles", href: "#principles" },
26
+ { label: "Who It's For", href: "#who" },
27
+ { label: "Contact", href: "#contact" },
28
+ ],
29
+ tagline = "Portable, Company-Owned\nOperational Intelligence",
30
+ copyright = `© ${new Date().getFullYear()} PCOI. Operational intelligence you own.`,
31
+ className = "",
32
+ ...rest
33
+ },
34
+ ref
35
+ ) => (
36
+ <footer ref={ref} className={`pcoi-footer ${className}`} {...rest}>
37
+ <div className="pcoi-footer__inner">
38
+ <div className="pcoi-footer__brand">
39
+ <LogoMark className="pcoi-footer__logo" />
40
+ <p className="pcoi-footer__tagline">{tagline}</p>
41
+ </div>
42
+ <div className="pcoi-footer__links">
43
+ {links.map((link) => (
44
+ <a key={link.href} href={link.href} data-track-id={link.trackingId}>{link.label}</a>
45
+ ))}
46
+ </div>
47
+ </div>
48
+ <div className="pcoi-footer__bottom">
49
+ <p>{copyright}</p>
50
+ </div>
51
+ </footer>
52
+ )
53
+ );
54
+
55
+ Footer.displayName = "Footer";
56
+ export default Footer;
@@ -0,0 +1 @@
1
+ export { Footer, type FooterProps, type FooterLink } from "./Footer";
@@ -0,0 +1,78 @@
1
+ /* FormField — @pcoi/components */
2
+
3
+ .pcoi-field {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: var(--pcoi-spacing-6);
7
+ }
8
+
9
+ .pcoi-field__label {
10
+ font-size: var(--pcoi-semantic-type-label-size);
11
+ font-weight: var(--pcoi-semantic-type-label-weight);
12
+ letter-spacing: var(--pcoi-semantic-type-label-letter-spacing);
13
+ text-transform: uppercase;
14
+ color: var(--pcoi-semantic-text-secondary);
15
+ }
16
+
17
+ .pcoi-field__required {
18
+ color: var(--pcoi-semantic-text-error);
19
+ margin-left: var(--pcoi-spacing-4);
20
+ }
21
+
22
+ .pcoi-field__input,
23
+ .pcoi-field__textarea {
24
+ font-family: var(--pcoi-semantic-type-body-font);
25
+ font-size: var(--pcoi-semantic-type-body-size);
26
+ color: var(--pcoi-semantic-text-primary);
27
+ background: var(--pcoi-semantic-bg-default);
28
+ border: 1px solid var(--pcoi-semantic-border-default);
29
+ border-radius: var(--pcoi-radius-sm);
30
+ padding: var(--pcoi-spacing-12) var(--pcoi-spacing-14);
31
+ transition: border-color var(--pcoi-effect-transition-fast, 0.2s ease),
32
+ box-shadow var(--pcoi-effect-transition-fast, 0.2s ease);
33
+ }
34
+
35
+ .pcoi-field__input::placeholder,
36
+ .pcoi-field__textarea::placeholder {
37
+ color: var(--pcoi-semantic-text-muted);
38
+ }
39
+
40
+ .pcoi-field__input:not(:disabled):not(:focus):hover,
41
+ .pcoi-field__textarea:not(:disabled):not(:focus):hover {
42
+ border-color: var(--pcoi-semantic-border-input-hover);
43
+ }
44
+
45
+ .pcoi-field__input:focus,
46
+ .pcoi-field__textarea:focus {
47
+ outline: none;
48
+ border-color: var(--pcoi-semantic-focus-border);
49
+ box-shadow: var(--pcoi-effect-shadow-focus-ring);
50
+ }
51
+
52
+ .pcoi-field__textarea {
53
+ resize: vertical;
54
+ min-height: var(--pcoi-semantic-sizing-textarea-min-height);
55
+ }
56
+
57
+ /* ── Error state ── */
58
+ .pcoi-field--error .pcoi-field__input,
59
+ .pcoi-field--error .pcoi-field__textarea {
60
+ border-color: var(--pcoi-semantic-border-error);
61
+ }
62
+
63
+ .pcoi-field--error .pcoi-field__input:focus,
64
+ .pcoi-field--error .pcoi-field__textarea:focus {
65
+ box-shadow: var(--pcoi-effect-shadow-focus-ring-error);
66
+ }
67
+
68
+ .pcoi-field__error {
69
+ font-size: var(--pcoi-semantic-type-label-size);
70
+ color: var(--pcoi-semantic-text-error);
71
+ margin: 0;
72
+ }
73
+
74
+ /* ── Disabled state ── */
75
+ .pcoi-field--disabled {
76
+ opacity: var(--pcoi-effect-opacity-disabled);
77
+ pointer-events: none;
78
+ }
@@ -0,0 +1,103 @@
1
+ import React from "react";
2
+
3
+ export interface FormFieldProps
4
+ extends Omit<
5
+ React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
6
+ "size"
7
+ > {
8
+ /** Visible label text */
9
+ label: string;
10
+ /** Unique field name, also used to generate id */
11
+ name: string;
12
+ /** Render as textarea instead of input */
13
+ multiline?: boolean;
14
+ /** Number of visible text rows when multiline (default 3) */
15
+ rows?: number;
16
+ /** Error message — when truthy, field enters error state */
17
+ error?: string;
18
+ }
19
+
20
+ /**
21
+ * PCOI FormField — Label + input/textarea atom
22
+ * Tokens: bg/default, border/default, border/error, focus/border, focus/glow,
23
+ * text/primary, text/secondary, text/muted, text/error,
24
+ * radius-sm, spacing-6/12/14, font-size/caption/body-default
25
+ */
26
+ export const FormField = React.forwardRef<
27
+ HTMLInputElement | HTMLTextAreaElement,
28
+ FormFieldProps
29
+ >(
30
+ (
31
+ {
32
+ label,
33
+ name,
34
+ multiline = false,
35
+ rows = 3,
36
+ error,
37
+ required = false,
38
+ disabled = false,
39
+ className = "",
40
+ ...props
41
+ },
42
+ ref
43
+ ) => {
44
+ const fieldId = `pcoi-field-${name}`;
45
+ const errorId = error ? `${fieldId}-error` : undefined;
46
+
47
+ const wrapperClasses = [
48
+ "pcoi-field",
49
+ error ? "pcoi-field--error" : "",
50
+ disabled ? "pcoi-field--disabled" : "",
51
+ className,
52
+ ]
53
+ .filter(Boolean)
54
+ .join(" ");
55
+
56
+ return (
57
+ <div className={wrapperClasses}>
58
+ <label htmlFor={fieldId} className="pcoi-field__label">
59
+ {label}
60
+ {required && (
61
+ <span className="pcoi-field__required" aria-hidden="true">
62
+ *
63
+ </span>
64
+ )}
65
+ </label>
66
+ {multiline ? (
67
+ <textarea
68
+ ref={ref as React.Ref<HTMLTextAreaElement>}
69
+ id={fieldId}
70
+ name={name}
71
+ rows={rows}
72
+ required={required}
73
+ disabled={disabled}
74
+ aria-invalid={!!error}
75
+ aria-describedby={errorId}
76
+ className="pcoi-field__textarea"
77
+ {...(props as React.TextareaHTMLAttributes<HTMLTextAreaElement>)}
78
+ />
79
+ ) : (
80
+ <input
81
+ ref={ref as React.Ref<HTMLInputElement>}
82
+ id={fieldId}
83
+ name={name}
84
+ required={required}
85
+ disabled={disabled}
86
+ aria-invalid={!!error}
87
+ aria-describedby={errorId}
88
+ className="pcoi-field__input"
89
+ {...(props as React.InputHTMLAttributes<HTMLInputElement>)}
90
+ />
91
+ )}
92
+ {error && (
93
+ <span id={errorId} className="pcoi-field__error" role="alert">
94
+ {error}
95
+ </span>
96
+ )}
97
+ </div>
98
+ );
99
+ }
100
+ );
101
+
102
+ FormField.displayName = "FormField";
103
+ export default FormField;
@@ -0,0 +1,2 @@
1
+ export { FormField, type FormFieldProps } from "./FormField";
2
+ export { default } from "./FormField";
@@ -0,0 +1,48 @@
1
+ /* HowStep — @pcoi/components */
2
+
3
+ .pcoi-how-step {
4
+ display: flex;
5
+ align-items: flex-start;
6
+ gap: var(--pcoi-spacing-32);
7
+ padding: var(--pcoi-spacing-40) 0;
8
+ border-bottom: 1px solid var(--pcoi-semantic-border-default);
9
+ }
10
+
11
+ .pcoi-how-step--last {
12
+ border-bottom: none;
13
+ }
14
+
15
+ .pcoi-how-step__number {
16
+ flex-shrink: 0;
17
+ width: var(--pcoi-semantic-sizing-step-number);
18
+ height: var(--pcoi-semantic-sizing-step-number);
19
+ border-radius: var(--pcoi-radius-full);
20
+ background: var(--pcoi-semantic-surface-accent-dim);
21
+ border: 1px solid var(--pcoi-semantic-border-accent-dim);
22
+ display: flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ font-family: var(--pcoi-semantic-type-mono-font);
26
+ font-size: var(--pcoi-semantic-type-body-sm-size);
27
+ font-weight: var(--pcoi-semantic-type-emphasis-weight);
28
+ color: var(--pcoi-semantic-text-accent);
29
+ }
30
+
31
+ .pcoi-how-step__content {
32
+ flex: 1;
33
+ min-width: 0;
34
+ }
35
+
36
+ .pcoi-how-step__title {
37
+ font-size: var(--pcoi-semantic-type-step-title-size);
38
+ font-weight: var(--pcoi-semantic-type-emphasis-weight);
39
+ color: var(--pcoi-semantic-text-primary);
40
+ margin: 0 0 var(--pcoi-spacing-8) 0;
41
+ }
42
+
43
+ .pcoi-how-step__desc {
44
+ font-size: var(--pcoi-semantic-type-body-size);
45
+ color: var(--pcoi-semantic-text-secondary);
46
+ line-height: var(--pcoi-semantic-type-step-line-height);
47
+ margin: 0;
48
+ }
@@ -0,0 +1,38 @@
1
+ import React from "react";
2
+ import type { HeadingLevel } from "../types";
3
+
4
+ export interface HowStepProps extends React.HTMLAttributes<HTMLDivElement> {
5
+ /** Step number displayed in circle (e.g., "01") */
6
+ number: string;
7
+ /** Step title */
8
+ title: string;
9
+ /** Step description */
10
+ description: string;
11
+ /** Whether this is the last step (no bottom border) */
12
+ isLast?: boolean;
13
+ /** Heading level for the title (default: "h3") */
14
+ headingLevel?: HeadingLevel;
15
+ }
16
+
17
+ /**
18
+ * PCOI How-It-Works Step
19
+ * Tokens: spacing-32 (gap), spacing-40 (padding-y), border/default, radius-full (number circle)
20
+ */
21
+ export const HowStep = React.forwardRef<HTMLDivElement, HowStepProps>(
22
+ ({ number, title, description, isLast = false, headingLevel = "h3", className = "", ...rest }, ref) => {
23
+ const Heading = headingLevel;
24
+
25
+ return (
26
+ <div ref={ref} className={`pcoi-how-step ${isLast ? "pcoi-how-step--last" : ""} ${className}`} {...rest}>
27
+ <div className="pcoi-how-step__number">{number}</div>
28
+ <div className="pcoi-how-step__content">
29
+ <Heading className="pcoi-how-step__title">{title}</Heading>
30
+ <p className="pcoi-how-step__desc">{description}</p>
31
+ </div>
32
+ </div>
33
+ );
34
+ }
35
+ );
36
+
37
+ HowStep.displayName = "HowStep";
38
+ export default HowStep;
@@ -0,0 +1 @@
1
+ export { HowStep, type HowStepProps } from "./HowStep";
@@ -0,0 +1,16 @@
1
+ /* LogoMark — @pcoi/components */
2
+
3
+ .pcoi-logo {
4
+ font-family: var(--pcoi-semantic-type-body-font);
5
+ font-size: var(--pcoi-semantic-type-logo-size);
6
+ font-weight: var(--pcoi-semantic-type-emphasis-weight);
7
+ letter-spacing: var(--pcoi-semantic-type-logo-letter-spacing);
8
+ color: var(--pcoi-semantic-text-primary);
9
+ text-decoration: none;
10
+ display: inline-flex;
11
+ align-items: center;
12
+ }
13
+
14
+ .pcoi-logo__mark {
15
+ color: var(--pcoi-semantic-text-accent);
16
+ }
@@ -0,0 +1,25 @@
1
+ import React from "react";
2
+
3
+ export interface LogoMarkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
4
+ }
5
+
6
+ /**
7
+ * PCOI Logo Mark — Atom
8
+ *
9
+ * Renders the brand mark: accent-colored "P" + "COI" text wrapped in an anchor.
10
+ * Single-sourced so Nav, Footer, and any future component share one implementation.
11
+ *
12
+ * Tokens consumed:
13
+ * - font-family/sans, font-size/logo, letterSpacing/logo
14
+ * - text/primary, text/accent
15
+ */
16
+ export const LogoMark = React.forwardRef<HTMLAnchorElement, LogoMarkProps>(
17
+ ({ href = "#", className = "", ...rest }, ref) => (
18
+ <a ref={ref} href={href} className={`pcoi-logo ${className}`} {...rest}>
19
+ <span className="pcoi-logo__mark">P</span>COI
20
+ </a>
21
+ )
22
+ );
23
+
24
+ LogoMark.displayName = "LogoMark";
25
+ export default LogoMark;
@@ -0,0 +1,2 @@
1
+ export { LogoMark, type LogoMarkProps } from "./LogoMark";
2
+ export { default } from "./LogoMark";
@@ -0,0 +1,101 @@
1
+ /* Modal — @pcoi/components */
2
+
3
+ .pcoi-modal {
4
+ position: fixed;
5
+ inset: 0;
6
+ z-index: var(--pcoi-layout-zIndex-modal, 500);
7
+ display: flex;
8
+ align-items: center;
9
+ justify-content: center;
10
+ background: var(--pcoi-semantic-bg-overlay);
11
+ padding: var(--pcoi-spacing-24);
12
+ animation: pcoi-modal-fade-in 0.2s ease;
13
+ }
14
+
15
+ .pcoi-modal__dialog {
16
+ font-family: var(--pcoi-semantic-type-body-font);
17
+ background: var(--pcoi-semantic-surface-elevated);
18
+ border: 1px solid var(--pcoi-semantic-border-default);
19
+ border-radius: var(--pcoi-radius-lg);
20
+ box-shadow: var(--pcoi-effect-shadow-elevated);
21
+ width: 100%;
22
+ max-width: var(--pcoi-layout-container-modal);
23
+ max-height: 85vh;
24
+ display: flex;
25
+ flex-direction: column;
26
+ animation: pcoi-modal-slide-up 0.2s ease;
27
+ }
28
+
29
+ .pcoi-modal__header {
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: space-between;
33
+ padding: var(--pcoi-spacing-20) var(--pcoi-spacing-24);
34
+ border-bottom: 1px solid var(--pcoi-semantic-border-default);
35
+ }
36
+
37
+ .pcoi-modal__title {
38
+ font-size: var(--pcoi-semantic-type-heading-sm-size);
39
+ font-weight: var(--pcoi-semantic-type-emphasis-weight);
40
+ color: var(--pcoi-semantic-text-primary);
41
+ margin: 0;
42
+ }
43
+
44
+ .pcoi-modal__close {
45
+ background: none;
46
+ border: none;
47
+ color: var(--pcoi-semantic-text-secondary);
48
+ font-size: var(--pcoi-semantic-type-close-lg-size);
49
+ line-height: var(--pcoi-semantic-type-none-line-height);
50
+ cursor: pointer;
51
+ padding: var(--pcoi-spacing-4);
52
+ border-radius: var(--pcoi-radius-sm);
53
+ transition: color var(--pcoi-effect-transition-fast, 0.2s ease);
54
+ }
55
+
56
+ .pcoi-modal__close:hover {
57
+ color: var(--pcoi-semantic-text-primary);
58
+ }
59
+
60
+ .pcoi-modal__close:active {
61
+ transform: var(--pcoi-effect-transform-press-scale-icon);
62
+ }
63
+
64
+ .pcoi-modal__close:focus-visible {
65
+ outline: none;
66
+ box-shadow: var(--pcoi-effect-shadow-focus-ring);
67
+ }
68
+
69
+ .pcoi-modal__body {
70
+ padding: var(--pcoi-spacing-24);
71
+ overflow-y: auto;
72
+ flex: 1;
73
+ font-size: var(--pcoi-semantic-type-body-size);
74
+ color: var(--pcoi-semantic-text-secondary);
75
+ line-height: var(--pcoi-semantic-type-body-line-height);
76
+ }
77
+
78
+ .pcoi-modal__footer {
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: flex-end;
82
+ gap: var(--pcoi-spacing-12);
83
+ padding: var(--pcoi-spacing-16) var(--pcoi-spacing-24);
84
+ border-top: 1px solid var(--pcoi-semantic-border-default);
85
+ }
86
+
87
+ /* ── Size variants ── */
88
+ .pcoi-modal__dialog--wide {
89
+ max-width: var(--pcoi-layout-container-document, 900px);
90
+ }
91
+
92
+ /* ── Animations ── */
93
+ @keyframes pcoi-modal-fade-in {
94
+ from { opacity: 0; }
95
+ to { opacity: 1; }
96
+ }
97
+
98
+ @keyframes pcoi-modal-slide-up {
99
+ from { transform: translateY(16px); opacity: 0; }
100
+ to { transform: translateY(0); opacity: 1; }
101
+ }
@@ -0,0 +1,141 @@
1
+ import React, { useEffect, useRef, useCallback } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { CloseIcon } from "../../../icons/src/react/CloseIcon";
4
+ import type { HeadingLevel } from "../types";
5
+
6
+ export interface ModalProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
7
+ /** Whether the modal is visible */
8
+ open: boolean;
9
+ /** Called when the modal should close */
10
+ onClose: () => void;
11
+ /** Modal title */
12
+ title?: string;
13
+ /** Heading level for the title (default: "h2") */
14
+ headingLevel?: HeadingLevel;
15
+ /** Dialog width: "default" (520px) or "wide" (900px) */
16
+ size?: "default" | "wide";
17
+ /** Modal body content */
18
+ children: React.ReactNode;
19
+ /** Footer content (e.g. action buttons) */
20
+ footer?: React.ReactNode;
21
+ }
22
+
23
+ /**
24
+ * PCOI Modal — Dialog overlay with focus trap
25
+ * Tokens: bg/overlay, surface/elevated, border/default, shadow/elevated,
26
+ * zIndex/modal, radius-lg, text/primary, text/secondary
27
+ */
28
+ export const Modal: React.FC<ModalProps> = ({
29
+ open,
30
+ onClose,
31
+ title,
32
+ headingLevel = "h2",
33
+ size = "default",
34
+ children,
35
+ footer,
36
+ className = "",
37
+ ...rest
38
+ }) => {
39
+ const dialogRef = useRef<HTMLDivElement>(null);
40
+ const previousFocusRef = useRef<HTMLElement | null>(null);
41
+
42
+ const Heading = headingLevel;
43
+ const titleId = title ? "pcoi-modal-title" : undefined;
44
+
45
+ const handleKeyDown = useCallback(
46
+ (e: KeyboardEvent) => {
47
+ if (e.key === "Escape") {
48
+ onClose();
49
+ return;
50
+ }
51
+
52
+ if (e.key === "Tab" && dialogRef.current) {
53
+ const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
54
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
55
+ );
56
+ if (focusable.length === 0) return;
57
+
58
+ const first = focusable[0];
59
+ const last = focusable[focusable.length - 1];
60
+
61
+ if (e.shiftKey && document.activeElement === first) {
62
+ e.preventDefault();
63
+ last.focus();
64
+ } else if (!e.shiftKey && document.activeElement === last) {
65
+ e.preventDefault();
66
+ first.focus();
67
+ }
68
+ }
69
+ },
70
+ [onClose]
71
+ );
72
+
73
+ useEffect(() => {
74
+ if (open) {
75
+ previousFocusRef.current = document.activeElement as HTMLElement;
76
+ document.addEventListener("keydown", handleKeyDown);
77
+ document.body.style.overflow = "hidden";
78
+
79
+ // Focus the dialog after render
80
+ requestAnimationFrame(() => {
81
+ const focusable = dialogRef.current?.querySelector<HTMLElement>(
82
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
83
+ );
84
+ focusable?.focus();
85
+ });
86
+ }
87
+
88
+ return () => {
89
+ document.removeEventListener("keydown", handleKeyDown);
90
+ document.body.style.overflow = "";
91
+ previousFocusRef.current?.focus();
92
+ };
93
+ }, [open, handleKeyDown]);
94
+
95
+ const handleBackdropClick = (e: React.MouseEvent) => {
96
+ if (e.target === e.currentTarget) {
97
+ onClose();
98
+ }
99
+ };
100
+
101
+ if (!open) return null;
102
+
103
+ const wrapperClasses = ["pcoi-modal", className].filter(Boolean).join(" ");
104
+
105
+ return createPortal(
106
+ <div className={wrapperClasses} onClick={handleBackdropClick}>
107
+ <div
108
+ ref={dialogRef}
109
+ className={`pcoi-modal__dialog${size === "wide" ? " pcoi-modal__dialog--wide" : ""}`}
110
+ role="dialog"
111
+ aria-modal="true"
112
+ aria-labelledby={titleId}
113
+ {...rest}
114
+ >
115
+ {(title || true) && (
116
+ <div className="pcoi-modal__header">
117
+ {title && (
118
+ <Heading id={titleId} className="pcoi-modal__title">
119
+ {title}
120
+ </Heading>
121
+ )}
122
+ <button
123
+ type="button"
124
+ className="pcoi-modal__close"
125
+ onClick={onClose}
126
+ aria-label="Close modal"
127
+ >
128
+ <CloseIcon size={20} />
129
+ </button>
130
+ </div>
131
+ )}
132
+ <div className="pcoi-modal__body">{children}</div>
133
+ {footer && <div className="pcoi-modal__footer">{footer}</div>}
134
+ </div>
135
+ </div>,
136
+ document.body
137
+ );
138
+ };
139
+
140
+ Modal.displayName = "Modal";
141
+ export default Modal;
@@ -0,0 +1,2 @@
1
+ export { Modal, type ModalProps } from "./Modal";
2
+ export { default } from "./Modal";