@nationaldesignstudio/react 0.0.8 → 0.0.9

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": "@nationaldesignstudio/react",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
@@ -19,7 +19,7 @@ const meta: Meta<typeof Accordion> = {
19
19
  },
20
20
  decorators: [
21
21
  (Story) => (
22
- <div className="bg-gray-1200 p-spacing-56 max-w-[746px]">
22
+ <div className="p-spacing-56 max-w-[746px]">
23
23
  <Story />
24
24
  </div>
25
25
  ),
@@ -58,23 +58,14 @@ export const CharcoalOutlineQuiet = () => (
58
58
  );
59
59
 
60
60
  export const Ivory = () => <Button variant="ivory">Ivory</Button>;
61
- Ivory.parameters = {
62
- backgrounds: { default: "dark" },
63
- };
64
61
 
65
62
  export const IvoryOutline = () => (
66
63
  <Button variant="ivoryOutline">Ivory Outline</Button>
67
64
  );
68
- IvoryOutline.parameters = {
69
- backgrounds: { default: "dark" },
70
- };
71
65
 
72
66
  export const IvoryOutlineQuiet = () => (
73
67
  <Button variant="ivoryOutlineQuiet">Ivory Outline Quiet</Button>
74
68
  );
75
- IvoryOutlineQuiet.parameters = {
76
- backgrounds: { default: "dark" },
77
- };
78
69
 
79
70
  // =============================================================================
80
71
  // Sizes
@@ -49,7 +49,7 @@ const buttonVariants = cva(
49
49
  ivoryOutlineQuiet:
50
50
  "border border-alpha-white-20 text-alpha-white-60 hover:border-alpha-white-30 hover:text-alpha-white-80 active:bg-alpha-white-5 focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
51
51
  // Secondary - gray filled button (for dark backgrounds)
52
- secondary:
52
+ gray:
53
53
  "bg-gray-800 text-gray-100 hover:bg-gray-700 active:bg-gray-600 focus-visible:ring-gray-700 focus-visible:ring-offset-gray-1000",
54
54
  },
55
55
  size: {
@@ -120,27 +120,18 @@ export const Ivory = () => (
120
120
  <SearchIcon />
121
121
  </IconButton>
122
122
  );
123
- Ivory.parameters = {
124
- backgrounds: { default: "dark" },
125
- };
126
123
 
127
124
  export const IvoryOutline = () => (
128
125
  <IconButton variant="ivoryOutline">
129
126
  <ArrowRightIcon />
130
127
  </IconButton>
131
128
  );
132
- IvoryOutline.parameters = {
133
- backgrounds: { default: "dark" },
134
- };
135
129
 
136
130
  export const IvoryOutlineQuiet = () => (
137
131
  <IconButton variant="ivoryOutlineQuiet">
138
132
  <ArrowRightIcon />
139
133
  </IconButton>
140
134
  );
141
- IvoryOutlineQuiet.parameters = {
142
- backgrounds: { default: "dark" },
143
- };
144
135
 
145
136
  // =============================================================================
146
137
  // Sizes
@@ -6,6 +6,26 @@ import { cn } from "@/lib/utils";
6
6
  /**
7
7
  * IconButton component based on Figma BaseKit / Interface / Icon Button
8
8
  *
9
+ * **IMPORTANT: Accessibility Requirement**
10
+ * Icon-only buttons MUST have an accessible label. Provide one of:
11
+ * - `aria-label`: A text description of the button's action (recommended)
12
+ * - `aria-labelledby`: Reference to an element containing the label
13
+ * - `title`: Tooltip text (less preferred, but provides a label)
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * // Correct usage with aria-label
18
+ * <IconButton aria-label="Close menu">
19
+ * <CloseIcon />
20
+ * </IconButton>
21
+ *
22
+ * // Correct usage with aria-labelledby
23
+ * <IconButton aria-labelledby="close-label">
24
+ * <CloseIcon />
25
+ * </IconButton>
26
+ * <span id="close-label" className="sr-only">Close menu</span>
27
+ * ```
28
+ *
9
29
  * Variants:
10
30
  * - charcoal: Dark filled button (for light backgrounds)
11
31
  * - charcoalOutline: Dark outlined button (for light backgrounds)
@@ -75,6 +95,21 @@ export interface IconButtonProps
75
95
 
76
96
  const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
77
97
  ({ className, variant, size, asChild = false, ...props }, ref) => {
98
+ // Development warning for missing accessible label
99
+ React.useEffect(() => {
100
+ if (import.meta.env?.DEV) {
101
+ const hasAccessibleLabel =
102
+ props["aria-label"] ||
103
+ props["aria-labelledby"] ||
104
+ props.title;
105
+ if (!hasAccessibleLabel) {
106
+ console.warn(
107
+ "IconButton: Missing accessible label. Icon-only buttons must have an aria-label, aria-labelledby, or title attribute for screen reader users.",
108
+ );
109
+ }
110
+ }
111
+ }, [props["aria-label"], props["aria-labelledby"], props.title]);
112
+
78
113
  const Comp = asChild ? Slot : "button";
79
114
  return (
80
115
  <Comp
@@ -79,9 +79,6 @@ Playground.args = {
79
79
  export const Charcoal = () => <PagerControl count={4} variant="charcoal" />;
80
80
 
81
81
  export const Ivory = () => <PagerControl count={4} variant="ivory" />;
82
- Ivory.parameters = {
83
- backgrounds: { default: "dark" },
84
- };
85
82
 
86
83
  // =============================================================================
87
84
  // Sizes
@@ -1,3 +1,4 @@
1
+ import { Dialog } from "@base-ui-components/react/dialog";
1
2
  import { Slot } from "@radix-ui/react-slot";
2
3
  import * as React from "react";
3
4
  import { cn } from "@/lib/utils";
@@ -168,6 +169,7 @@ export interface NavbarMobileMenuProps
168
169
  /**
169
170
  * Mobile menu container that displays navigation links on mobile devices.
170
171
  * Hidden on desktop (md and above). Should be used with NavbarMobileMenuButton.
172
+ * Built on Base UI Dialog for accessibility (focus trap, escape key, click-outside).
171
173
  */
172
174
  const NavbarMobileMenu = React.forwardRef<
173
175
  HTMLDivElement,
@@ -175,87 +177,37 @@ const NavbarMobileMenu = React.forwardRef<
175
177
  >(({ className, children, ...props }, ref) => {
176
178
  const { isMobileMenuOpen, setIsMobileMenuOpen } =
177
179
  React.useContext(NavbarContext);
178
- const menuRef = React.useRef<HTMLDivElement>(null);
179
-
180
- // Close menu when clicking outside
181
- React.useEffect(() => {
182
- if (!isMobileMenuOpen) return;
183
-
184
- const handleClickOutside = (event: MouseEvent) => {
185
- const target = event.target as Node;
186
- if (menuRef.current && !menuRef.current.contains(target)) {
187
- setIsMobileMenuOpen(false);
188
- }
189
- };
190
-
191
- // Use setTimeout to avoid immediate close on button click
192
- const timeoutId = setTimeout(() => {
193
- document.addEventListener("mousedown", handleClickOutside);
194
- }, 0);
195
-
196
- return () => {
197
- clearTimeout(timeoutId);
198
- document.removeEventListener("mousedown", handleClickOutside);
199
- };
200
- }, [isMobileMenuOpen, setIsMobileMenuOpen]);
201
-
202
- // Prevent body scroll when menu is open
203
- React.useEffect(() => {
204
- if (isMobileMenuOpen) {
205
- document.body.style.overflow = "hidden";
206
- } else {
207
- document.body.style.overflow = "";
208
- }
209
- return () => {
210
- document.body.style.overflow = "";
211
- };
212
- }, [isMobileMenuOpen]);
213
-
214
- // Close menu on escape key
215
- React.useEffect(() => {
216
- if (!isMobileMenuOpen) return;
217
-
218
- const handleEscape = (event: KeyboardEvent) => {
219
- if (event.key === "Escape") {
220
- setIsMobileMenuOpen(false);
221
- }
222
- };
223
-
224
- document.addEventListener("keydown", handleEscape);
225
- return () => document.removeEventListener("keydown", handleEscape);
226
- }, [isMobileMenuOpen, setIsMobileMenuOpen]);
227
-
228
- if (!isMobileMenuOpen) return null;
229
180
 
230
181
  return (
231
- <div
232
- ref={menuRef}
233
- className={cn(
234
- "fixed inset-0 z-50 bg-gray-50 md:hidden",
235
- // Position below navbar
236
- "pt-[calc(var(--navbar-height,60px)+1px)]",
237
- // Smooth transition
238
- "transition-opacity duration-200",
239
- className,
240
- )}
241
- role="dialog"
242
- aria-modal="true"
243
- aria-label="Mobile navigation menu"
244
- {...props}
245
- >
246
- <div
247
- ref={ref}
248
- className={cn(
249
- "flex flex-col",
250
- // Padding matching navbar
251
- "px-spacing-20 py-spacing-16",
252
- // Gap between links
253
- "gap-spacing-8",
254
- )}
255
- >
256
- {children}
257
- </div>
258
- </div>
182
+ <Dialog.Root open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
183
+ <Dialog.Portal>
184
+ <Dialog.Popup
185
+ ref={ref}
186
+ className={cn(
187
+ "fixed inset-0 z-50 bg-gray-50 md:hidden",
188
+ // Position below navbar
189
+ "pt-[calc(var(--navbar-height,60px)+1px)]",
190
+ // Smooth transition
191
+ "transition-opacity duration-200",
192
+ className,
193
+ )}
194
+ {...props}
195
+ >
196
+ <Dialog.Title className="sr-only">Navigation menu</Dialog.Title>
197
+ <div
198
+ className={cn(
199
+ "flex flex-col",
200
+ // Padding matching navbar
201
+ "px-spacing-20 py-spacing-16",
202
+ // Gap between links
203
+ "gap-spacing-8",
204
+ )}
205
+ >
206
+ {children}
207
+ </div>
208
+ </Dialog.Popup>
209
+ </Dialog.Portal>
210
+ </Dialog.Root>
259
211
  );
260
212
  });
261
213
  NavbarMobileMenu.displayName = "NavbarMobileMenu";
@@ -86,8 +86,6 @@ export interface BannerProps
86
86
  */
87
87
  const Banner = React.forwardRef<HTMLElement, BannerProps>(
88
88
  ({ className, theme, heading, description, action, ...props }, ref) => {
89
- const isDark = theme === "dark";
90
-
91
89
  return (
92
90
  <section
93
91
  ref={ref}
@@ -98,7 +96,7 @@ const Banner = React.forwardRef<HTMLElement, BannerProps>(
98
96
  <div
99
97
  className={cn(
100
98
  "flex flex-col md:flex-row gap-spacing-20 items-start md:items-center md:justify-between",
101
- isDark && "border-t border-gray-700 py-spacing-36",
99
+ theme === "dark" && "border-t border-gray-700 py-spacing-36",
102
100
  )}
103
101
  >
104
102
  {/* Copy section */}
@@ -106,7 +104,7 @@ const Banner = React.forwardRef<HTMLElement, BannerProps>(
106
104
  <h2
107
105
  className={cn(
108
106
  "typography-subheading-small",
109
- isDark ? "text-gray-100" : "text-gray-900",
107
+ theme === "dark" ? "text-gray-100" : "text-gray-900",
110
108
  )}
111
109
  >
112
110
  {heading}
@@ -114,7 +112,7 @@ const Banner = React.forwardRef<HTMLElement, BannerProps>(
114
112
  <p
115
113
  className={cn(
116
114
  "typography-body-small",
117
- isDark ? "text-gray-500" : "text-gray-800",
115
+ theme === "dark" ? "text-gray-500" : "text-gray-800",
118
116
  )}
119
117
  >
120
118
  {description}
@@ -48,13 +48,14 @@ export interface FaqSectionProps
48
48
  */
49
49
  const FaqSection = React.forwardRef<HTMLElement, FaqSectionProps>(
50
50
  (
51
- { className, theme = "dark", title = "Frequently Asked Questions", children, ...props },
51
+ { className, theme = "dark", title = "Frequently Asked Questions", children, layout, ...props },
52
52
  ref,
53
53
  ) => {
54
54
  return (
55
55
  <TwoColumnSection
56
56
  ref={ref}
57
57
  theme={theme}
58
+ layout={layout ?? undefined}
58
59
  title={title}
59
60
  className={cn(
60
61
  // Override title typography to be larger
@@ -154,6 +154,7 @@ const Hero = React.forwardRef<HTMLElement, HeroProps>(
154
154
  loop
155
155
  muted
156
156
  playsInline
157
+ aria-hidden="true"
157
158
  className="absolute inset-0 h-full w-full object-cover"
158
159
  >
159
160
  <source src={backgroundVideo} />
@@ -192,6 +192,7 @@ const Tout = React.forwardRef<HTMLElement, ToutProps>(
192
192
  className="hover:underline"
193
193
  >
194
194
  National Design Studio
195
+ <span className="sr-only"> (opens in new tab)</span>
195
196
  </a>
196
197
  </p>
197
198
  </div>
@@ -87,10 +87,7 @@ export interface TwoColumnSectionProps
87
87
  * ```
88
88
  */
89
89
  const TwoColumnSection = React.forwardRef<HTMLElement, TwoColumnSectionProps>(
90
- ({ className, theme, layout, title, lead, children, ...props }, ref) => {
91
- const isDark = theme === "dark" || theme === undefined;
92
- const isEqual = layout === "equal";
93
-
90
+ ({ className, theme = "dark", layout, title, lead, children, ...props }, ref) => {
94
91
  return (
95
92
  <section
96
93
  ref={ref}
@@ -101,10 +98,10 @@ const TwoColumnSection = React.forwardRef<HTMLElement, TwoColumnSectionProps>(
101
98
  <div
102
99
  className={cn(
103
100
  "border-t pt-spacing-36",
104
- isDark ? "border-gray-700" : "border-gray-300",
101
+ theme === "dark" ? "border-gray-700" : "border-gray-300",
105
102
  // Grid layout
106
103
  "grid grid-cols-1 gap-spacing-56",
107
- isEqual
104
+ layout === "equal"
108
105
  ? "md:grid-cols-2"
109
106
  : "lg:grid-cols-24 lg:gap-spacing-56",
110
107
  )}
@@ -113,22 +110,22 @@ const TwoColumnSection = React.forwardRef<HTMLElement, TwoColumnSectionProps>(
113
110
  <h2
114
111
  className={cn(
115
112
  "typography-subheading-medium",
116
- isDark ? "text-gray-100" : "text-gray-900",
113
+ theme === "dark" ? "text-gray-100" : "text-gray-900",
117
114
  // Column span based on layout
118
- !isEqual && "lg:col-span-9",
115
+ layout !== "equal" && "lg:col-span-9",
119
116
  )}
120
117
  >
121
118
  {title}
122
119
  </h2>
123
120
 
124
121
  {/* Content column */}
125
- <div className={cn("flex flex-col gap-spacing-56", !isEqual && "lg:col-span-15")}>
122
+ <div className={cn("flex flex-col gap-spacing-56", layout !== "equal" && "lg:col-span-15")}>
126
123
  {/* Lead content - brighter/prominent */}
127
124
  {lead && (
128
125
  <div
129
126
  className={cn(
130
127
  "typography-body-large",
131
- isDark ? "text-gray-100" : "text-gray-900",
128
+ theme === "dark" ? "text-gray-100" : "text-gray-900",
132
129
  )}
133
130
  >
134
131
  {typeof lead === "string" ? <p>{lead}</p> : lead}
@@ -139,7 +136,7 @@ const TwoColumnSection = React.forwardRef<HTMLElement, TwoColumnSectionProps>(
139
136
  <div
140
137
  className={cn(
141
138
  "typography-body-medium flex flex-col gap-[1em]",
142
- isDark ? "text-gray-400" : "text-gray-600",
139
+ theme === "dark" ? "text-gray-400" : "text-gray-600",
143
140
  )}
144
141
  >
145
142
  {children}
@@ -39,7 +39,7 @@ export const Colors: Story = {
39
39
 
40
40
  export const Spacing: Story = {
41
41
  render: () => (
42
- <div className="p-8 bg-gray-50 min-h-screen">
42
+ <div className="p-8 min-h-screen">
43
43
  <div className="max-w-4xl mx-auto">
44
44
  <SpacingTokens />
45
45
  </div>
@@ -49,7 +49,7 @@ export const Spacing: Story = {
49
49
 
50
50
  export const Typography: Story = {
51
51
  render: () => (
52
- <div className="p-8 bg-gray-50 min-h-screen">
52
+ <div className="p-8 min-h-screen">
53
53
  <div className="max-w-6xl mx-auto">
54
54
  <TypographyTokens />
55
55
  </div>
@@ -59,7 +59,7 @@ export const Typography: Story = {
59
59
 
60
60
  export const SemanticSpacing: Story = {
61
61
  render: () => (
62
- <div className="p-8 bg-gray-50 min-h-screen">
62
+ <div className="p-8 min-h-screen">
63
63
  <div className="max-w-4xl mx-auto">
64
64
  <SemanticSpacingTokens />
65
65
  </div>