@pcoi/components 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pcoi/components",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "PCOI Design System — React UI components",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -11,9 +11,9 @@
11
11
  ],
12
12
  "exports": {
13
13
  ".": {
14
+ "types": "./dist/index.d.ts",
14
15
  "import": "./dist/index.mjs",
15
- "require": "./dist/index.js",
16
- "types": "./dist/index.d.ts"
16
+ "require": "./dist/index.js"
17
17
  },
18
18
  "./css": "./dist/components.css"
19
19
  },
@@ -22,9 +22,14 @@
22
22
  line-height: var(--pcoi-semantic-type-body-compact-line-height);
23
23
  max-width: var(--pcoi-semantic-sizing-callout-width, 640px);
24
24
  margin: 0 0 var(--pcoi-semantic-spacing-panel-padding) 0;
25
+ padding: 0;
25
26
  font-style: italic;
26
27
  }
27
28
 
29
+ .pcoi-callout__quote p {
30
+ margin: 0;
31
+ }
32
+
28
33
  .pcoi-callout__attribution {
29
34
  font-size: var(--pcoi-semantic-type-body-sm-size);
30
35
  color: var(--pcoi-semantic-text-muted);
@@ -1,37 +1,41 @@
1
1
  import React from "react";
2
2
 
3
- export interface CalloutProps extends React.HTMLAttributes<HTMLDivElement> {
3
+ export interface CalloutProps extends React.HTMLAttributes<HTMLElement> {
4
4
  /** Quote text */
5
5
  quote: string;
6
6
  /** Attribution name */
7
7
  attribution?: string;
8
8
  /** Attribution title/context */
9
9
  attributionTitle?: string;
10
- /** Link URL */
10
+ /** Link URL for the source reference */
11
11
  sourceUrl?: string;
12
- /** Link label */
12
+ /** Display label for the source link (default: "Source") */
13
13
  sourceLabel?: string;
14
14
  }
15
15
 
16
16
  /**
17
17
  * PCOI Callout / Blockquote
18
+ *
19
+ * Renders a semantic `<figure>` with `<blockquote>` and optional `<figcaption>`.
18
20
  * Tokens: spacing-40 (padding), text/secondary, text/accent (link)
19
21
  */
20
- export const Callout = React.forwardRef<HTMLDivElement, CalloutProps>(
22
+ export const Callout = React.forwardRef<HTMLElement, CalloutProps>(
21
23
  ({ quote, attribution, attributionTitle, sourceUrl, sourceLabel, className = "", ...rest }, ref) => (
22
- <div ref={ref} className={`pcoi-callout ${className}`} {...rest}>
23
- <div className="pcoi-callout__line" />
24
- <p className="pcoi-callout__quote">{quote}</p>
24
+ <figure ref={ref} className={`pcoi-callout ${className}`} {...rest}>
25
+ <div className="pcoi-callout__line" aria-hidden="true" />
26
+ <blockquote className="pcoi-callout__quote">
27
+ <p>{quote}</p>
28
+ </blockquote>
25
29
  {(attribution || sourceUrl) && (
26
- <p className="pcoi-callout__attribution">
30
+ <figcaption className="pcoi-callout__attribution">
27
31
  {attributionTitle && <span>{attributionTitle} &nbsp;~&nbsp; </span>}
28
32
  {attribution}
29
33
  {sourceUrl && (
30
34
  <> &nbsp;|&nbsp; <a href={sourceUrl} target="_blank" rel="noopener noreferrer">{sourceLabel || "Source"}</a></>
31
35
  )}
32
- </p>
36
+ </figcaption>
33
37
  )}
34
- </div>
38
+ </figure>
35
39
  )
36
40
  );
37
41
 
@@ -3,6 +3,7 @@
3
3
  .pcoi-chat {
4
4
  display: flex;
5
5
  flex-direction: column;
6
+ width: 100%;
6
7
  height: 100%;
7
8
  max-width: var(--pcoi-semantic-sizing-container-narrow, 800px);
8
9
  margin: 0 auto;
@@ -98,7 +98,7 @@ describe("ChatInterface integration", () => {
98
98
 
99
99
  render(<ChatCitationHarness />);
100
100
 
101
- fireEvent.click(screen.getByRole("button", { name: "Knowledge Report" }));
101
+ fireEvent.click(screen.getByRole("button", { name: "View source: Knowledge Report" }));
102
102
 
103
103
  expect(screen.getByRole("dialog")).toBeInTheDocument();
104
104
  expect(screen.getByText("Knowledge Retention Report")).toBeInTheDocument();
@@ -7,10 +7,15 @@ import { TypingIndicator } from "../TypingIndicator/TypingIndicator";
7
7
  import type { ChatMessageRole, Citation, Suggestion } from "../types";
8
8
 
9
9
  export interface ChatInterfaceMessage {
10
+ /** Unique message identifier */
10
11
  id: string;
12
+ /** Sender role: "user" or "assistant" */
11
13
  role: ChatMessageRole;
14
+ /** Message body content */
12
15
  content: React.ReactNode;
16
+ /** Citations referenced in the message */
13
17
  citations?: Citation[];
18
+ /** ISO timestamp displayed below the message */
14
19
  timestamp?: string;
15
20
  }
16
21
 
@@ -85,7 +90,7 @@ export const ChatInterface = React.forwardRef<
85
90
  )}
86
91
  </div>
87
92
  ) : (
88
- <ChatMessageList className="pcoi-chat__messages">
93
+ <ChatMessageList className="pcoi-chat__messages" aria-live="polite" aria-relevant="additions">
89
94
  {messages.map((msg) => (
90
95
  <ChatMessage
91
96
  key={msg.id}
@@ -30,14 +30,17 @@ describe("ChatMessageList", () => {
30
30
 
31
31
  expect(screen.getByText("Assistant response")).toBeInTheDocument();
32
32
 
33
- fireEvent.click(screen.getByRole("button", { name: "Knowledge Report" }));
34
- fireEvent.click(screen.getByRole("button", { name: "[1]" }));
33
+ fireEvent.click(screen.getByRole("button", { name: "View source: Knowledge Report" }));
34
+ fireEvent.click(screen.getByRole("button", { name: "Citation 1, view source: Knowledge Report" }));
35
35
 
36
36
  expect(onCitationClick).toHaveBeenCalledTimes(2);
37
37
  expect(onCitationClick).toHaveBeenCalledWith(citation);
38
38
  });
39
39
 
40
- it("auto-scrolls to bottom when new messages are added", async () => {
40
+ it("auto-scrolls last message into view when new messages are added", async () => {
41
+ const scrollIntoViewMock = vi.fn();
42
+ Element.prototype.scrollIntoView = scrollIntoViewMock;
43
+
41
44
  vi.spyOn(window, "requestAnimationFrame").mockImplementation(
42
45
  (cb: FrameRequestCallback) => {
43
46
  cb(0);
@@ -45,20 +48,13 @@ describe("ChatMessageList", () => {
45
48
  }
46
49
  );
47
50
 
48
- const { container, rerender } = render(
51
+ const { rerender } = render(
49
52
  <ChatMessageList>
50
53
  <div>Message 1</div>
51
54
  </ChatMessageList>
52
55
  );
53
56
 
54
- const inner = container.querySelector(
55
- ".pcoi-chat-message-list__inner"
56
- ) as HTMLDivElement;
57
-
58
- Object.defineProperty(inner, "scrollHeight", {
59
- configurable: true,
60
- get: () => 480,
61
- });
57
+ scrollIntoViewMock.mockClear();
62
58
 
63
59
  rerender(
64
60
  <ChatMessageList>
@@ -68,7 +64,7 @@ describe("ChatMessageList", () => {
68
64
  );
69
65
 
70
66
  await waitFor(() => {
71
- expect(inner.scrollTop).toBe(480);
67
+ expect(scrollIntoViewMock).toHaveBeenCalledWith({ block: "nearest" });
72
68
  });
73
69
  });
74
70
  });
@@ -23,7 +23,12 @@ export const ChatMessageList = React.forwardRef<
23
23
 
24
24
  const scrollToEnd = () => {
25
25
  requestAnimationFrame(() => {
26
- el.scrollTop = el.scrollHeight;
26
+ const lastChild = el.lastElementChild;
27
+ if (lastChild) {
28
+ lastChild.scrollIntoView({ block: "nearest" });
29
+ } else {
30
+ el.scrollTop = el.scrollHeight;
31
+ }
27
32
  });
28
33
  };
29
34
 
@@ -39,7 +44,7 @@ export const ChatMessageList = React.forwardRef<
39
44
  .join(" ");
40
45
 
41
46
  return (
42
- <div ref={ref} className={classes} {...rest}>
47
+ <div ref={ref} className={classes} role="log" aria-label="Conversation" {...rest}>
43
48
  <div ref={innerRef} className="pcoi-chat-message-list__inner">
44
49
  {children}
45
50
  </div>
@@ -29,6 +29,7 @@ export const CitedExcerpt = React.forwardRef<HTMLDivElement, CitedExcerptProps>(
29
29
  type="button"
30
30
  className="pcoi-cited-excerpt__source"
31
31
  onClick={onSourceClick}
32
+ aria-label={`View source: ${sourceTitle}`}
32
33
  >
33
34
  {sourceTitle}
34
35
  </button>
@@ -37,6 +38,7 @@ export const CitedExcerpt = React.forwardRef<HTMLDivElement, CitedExcerptProps>(
37
38
  type="button"
38
39
  className="pcoi-cited-excerpt__index"
39
40
  onClick={onSourceClick}
41
+ aria-label={`Citation ${index}, view source: ${sourceTitle}`}
40
42
  >
41
43
  [{index}]
42
44
  </button>
@@ -1,14 +1,20 @@
1
1
  import React from "react";
2
2
 
3
3
  export interface ComparisonRow {
4
+ /** Feature label shown in the first column */
4
5
  label: string;
6
+ /** Competitor's value for this feature */
5
7
  competitor: string;
8
+ /** PCOI's value for this feature */
6
9
  pcoi: string;
7
10
  }
8
11
 
9
12
  export interface ComparisonTableProps extends React.HTMLAttributes<HTMLDivElement> {
13
+ /** Column header for the competitor (default: "Typical SaaS AI") */
10
14
  competitorName?: string;
15
+ /** Column header for PCOI (default: "PCOI") */
11
16
  pcoiName?: string;
17
+ /** Feature comparison rows */
12
18
  rows: ComparisonRow[];
13
19
  }
14
20
 
@@ -3,6 +3,7 @@ import { Button } from "../Button";
3
3
  import { FormField } from "../FormField";
4
4
 
5
5
  export interface ContactFormProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, "onSubmit"> {
6
+ /** Called with form field values when the form is submitted */
6
7
  onSubmit?: (data: Record<string, string>) => void;
7
8
  }
8
9
 
@@ -31,7 +31,7 @@ export const DocumentOverlay = React.forwardRef<
31
31
  >(
32
32
  (
33
33
  { open, onClose, title, sourceLabel, children, highlightId, highlightIndex, className = "", ...rest },
34
- _ref
34
+ ref
35
35
  ) => {
36
36
  const contentRef = useRef<HTMLDivElement>(null);
37
37
 
@@ -65,6 +65,7 @@ export const DocumentOverlay = React.forwardRef<
65
65
  return React.createElement(
66
66
  Modal,
67
67
  {
68
+ ref,
68
69
  open,
69
70
  onClose,
70
71
  title,
@@ -6,8 +6,11 @@ import type { LinkItem } from "../types";
6
6
  export type FooterLink = LinkItem;
7
7
 
8
8
  export interface FooterProps extends React.HTMLAttributes<HTMLElement> {
9
+ /** Navigation links rendered in the footer */
9
10
  links?: LinkItem[];
11
+ /** Brand tagline displayed alongside the logo */
10
12
  tagline?: string;
13
+ /** Copyright notice shown at the bottom */
11
14
  copyright?: string;
12
15
  }
13
16
 
@@ -39,11 +42,11 @@ export const Footer = React.forwardRef<HTMLElement, FooterProps>(
39
42
  <LogoMark className="pcoi-footer__logo" />
40
43
  <p className="pcoi-footer__tagline">{tagline}</p>
41
44
  </div>
42
- <div className="pcoi-footer__links">
45
+ <nav className="pcoi-footer__links" aria-label="Footer">
43
46
  {links.map((link) => (
44
47
  <a key={link.href} href={link.href} data-track-id={link.trackingId}>{link.label}</a>
45
48
  ))}
46
- </div>
49
+ </nav>
47
50
  </div>
48
51
  <div className="pcoi-footer__bottom">
49
52
  <p>{copyright}</p>
@@ -14,8 +14,8 @@ export type LogoMarkProps = React.AnchorHTMLAttributes<HTMLAnchorElement>;
14
14
  */
15
15
  export const LogoMark = React.forwardRef<HTMLAnchorElement, LogoMarkProps>(
16
16
  ({ href = "#", className = "", ...rest }, ref) => (
17
- <a ref={ref} href={href} className={`pcoi-logo ${className}`} {...rest}>
18
- <span className="pcoi-logo__mark">P</span>COI
17
+ <a ref={ref} href={href} className={`pcoi-logo ${className}`} aria-label="PCOI home" {...rest}>
18
+ <span className="pcoi-logo__mark" aria-hidden="true">P</span>COI
19
19
  </a>
20
20
  )
21
21
  );
@@ -25,115 +25,120 @@ export interface ModalProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "
25
25
  * Tokens: bg/overlay, surface/elevated, border/default, shadow/elevated,
26
26
  * zIndex/modal, radius-lg, text/primary, text/secondary
27
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);
28
+ export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
29
+ (
30
+ {
31
+ open,
32
+ onClose,
33
+ title,
34
+ headingLevel = "h2",
35
+ size = "default",
36
+ children,
37
+ footer,
38
+ className = "",
39
+ ...rest
40
+ },
41
+ _ref
42
+ ) => {
43
+ const dialogRef = useRef<HTMLDivElement>(null);
44
+ const previousFocusRef = useRef<HTMLElement | null>(null);
41
45
 
42
- const Heading = headingLevel;
43
- const titleId = title ? "pcoi-modal-title" : undefined;
46
+ const Heading = headingLevel;
47
+ const titleId = title ? "pcoi-modal-title" : undefined;
44
48
 
45
- const handleKeyDown = useCallback(
46
- (e: KeyboardEvent) => {
47
- if (e.key === "Escape") {
48
- onClose();
49
- return;
50
- }
49
+ const handleKeyDown = useCallback(
50
+ (e: KeyboardEvent) => {
51
+ if (e.key === "Escape") {
52
+ onClose();
53
+ return;
54
+ }
51
55
 
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;
56
+ if (e.key === "Tab" && dialogRef.current) {
57
+ const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
58
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
59
+ );
60
+ if (focusable.length === 0) return;
57
61
 
58
- const first = focusable[0];
59
- const last = focusable[focusable.length - 1];
62
+ const first = focusable[0];
63
+ const last = focusable[focusable.length - 1];
60
64
 
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();
65
+ if (e.shiftKey && document.activeElement === first) {
66
+ e.preventDefault();
67
+ last.focus();
68
+ } else if (!e.shiftKey && document.activeElement === last) {
69
+ e.preventDefault();
70
+ first.focus();
71
+ }
67
72
  }
68
- }
69
- },
70
- [onClose]
71
- );
73
+ },
74
+ [onClose]
75
+ );
72
76
 
73
- useEffect(() => {
74
- if (open) {
75
- previousFocusRef.current = document.activeElement as HTMLElement;
76
- document.addEventListener("keydown", handleKeyDown);
77
- document.body.style.overflow = "hidden";
77
+ useEffect(() => {
78
+ if (open) {
79
+ previousFocusRef.current = document.activeElement as HTMLElement;
80
+ document.addEventListener("keydown", handleKeyDown);
81
+ document.body.style.overflow = "hidden";
78
82
 
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
- }
83
+ // Focus the dialog after render
84
+ requestAnimationFrame(() => {
85
+ const focusable = dialogRef.current?.querySelector<HTMLElement>(
86
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
87
+ );
88
+ focusable?.focus();
89
+ });
90
+ }
87
91
 
88
- return () => {
89
- document.removeEventListener("keydown", handleKeyDown);
90
- document.body.style.overflow = "";
91
- previousFocusRef.current?.focus();
92
- };
93
- }, [open, handleKeyDown]);
92
+ return () => {
93
+ document.removeEventListener("keydown", handleKeyDown);
94
+ document.body.style.overflow = "";
95
+ previousFocusRef.current?.focus();
96
+ };
97
+ }, [open, handleKeyDown]);
94
98
 
95
- const handleBackdropClick = (e: React.MouseEvent) => {
96
- if (e.target === e.currentTarget) {
97
- onClose();
98
- }
99
- };
99
+ const handleBackdropClick = (e: React.MouseEvent) => {
100
+ if (e.target === e.currentTarget) {
101
+ onClose();
102
+ }
103
+ };
100
104
 
101
- if (!open) return null;
105
+ if (!open) return null;
102
106
 
103
- const wrapperClasses = ["pcoi-modal", className].filter(Boolean).join(" ");
107
+ const wrapperClasses = ["pcoi-modal", className].filter(Boolean).join(" ");
104
108
 
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
- <div className="pcoi-modal__header">
116
- {title && (
117
- <Heading id={titleId} className="pcoi-modal__title">
118
- {title}
119
- </Heading>
120
- )}
121
- <button
122
- type="button"
123
- className="pcoi-modal__close"
124
- onClick={onClose}
125
- aria-label="Close modal"
126
- >
127
- <CloseIcon size={20} />
128
- </button>
109
+ return createPortal(
110
+ <div className={wrapperClasses} onClick={handleBackdropClick}>
111
+ <div
112
+ ref={dialogRef}
113
+ className={`pcoi-modal__dialog${size === "wide" ? " pcoi-modal__dialog--wide" : ""}`}
114
+ role="dialog"
115
+ aria-modal="true"
116
+ aria-labelledby={titleId}
117
+ {...rest}
118
+ >
119
+ <div className="pcoi-modal__header">
120
+ {title && (
121
+ <Heading id={titleId} className="pcoi-modal__title">
122
+ {title}
123
+ </Heading>
124
+ )}
125
+ <button
126
+ type="button"
127
+ className="pcoi-modal__close"
128
+ onClick={onClose}
129
+ aria-label="Close modal"
130
+ >
131
+ <CloseIcon size={20} />
132
+ </button>
133
+ </div>
134
+ <div className="pcoi-modal__body">{children}</div>
135
+ {footer && <div className="pcoi-modal__footer">{footer}</div>}
129
136
  </div>
130
- <div className="pcoi-modal__body">{children}</div>
131
- {footer && <div className="pcoi-modal__footer">{footer}</div>}
132
- </div>
133
- </div>,
134
- document.body
135
- );
136
- };
137
+ </div>,
138
+ document.body
139
+ );
140
+ }
141
+ );
137
142
 
138
143
  Modal.displayName = "Modal";
139
144
  export default Modal;
package/src/Nav/Nav.tsx CHANGED
@@ -7,9 +7,13 @@ import type { LinkItem } from "../types";
7
7
  export type NavLink = LinkItem;
8
8
 
9
9
  export interface NavProps extends React.HTMLAttributes<HTMLElement> {
10
+ /** Navigation links rendered in the navbar */
10
11
  links?: LinkItem[];
12
+ /** Label text for the call-to-action button */
11
13
  ctaLabel?: string;
14
+ /** URL the CTA button links to */
12
15
  ctaHref?: string;
16
+ /** Called when the CTA button is clicked */
13
17
  onCtaClick?: () => void;
14
18
  }
15
19
 
@@ -82,9 +86,9 @@ export const Nav = React.forwardRef<HTMLElement, NavProps>(
82
86
  <span /><span /><span />
83
87
  </button>
84
88
  </div>
85
- <div className="pcoi-nav__mobile-menu" id="pcoi-mobile-menu">
89
+ <div className="pcoi-nav__mobile-menu" id="pcoi-mobile-menu" role="menu" aria-hidden={!menuOpen}>
86
90
  {links.map((link) => (
87
- <a key={link.href} href={link.href} data-track-id={link.trackingId} onClick={() => setMenuOpen(false)}>
91
+ <a key={link.href} href={link.href} data-track-id={link.trackingId} role="menuitem" tabIndex={menuOpen ? 0 : -1} onClick={() => setMenuOpen(false)}>
88
92
  {link.label}
89
93
  </a>
90
94
  ))}
@@ -2,7 +2,9 @@ import React from "react";
2
2
  import type { HeadingLevel } from "../types";
3
3
 
4
4
  export interface SignalsPanelProps extends React.HTMLAttributes<HTMLDivElement> {
5
+ /** Panel heading text */
5
6
  title?: string;
7
+ /** List of signal statements displayed as bullet points */
6
8
  signals: string[];
7
9
  /** Heading level for the title (default: "h3") */
8
10
  headingLevel?: HeadingLevel;