@a11ypros/a11y-ui-components 1.0.0 → 1.0.1

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 (217) hide show
  1. package/.storybook/custom.css +69 -0
  2. package/.storybook/main.ts +46 -0
  3. package/.storybook/manager.ts +26 -0
  4. package/.storybook/package.json +6 -0
  5. package/.storybook/preview.tsx +31 -0
  6. package/.storybook/public/logo.png +0 -0
  7. package/.storybook/vite.config.ts +24 -0
  8. package/.storybook/welcome.mdx +97 -0
  9. package/DEPLOYMENT.md +154 -0
  10. package/README.md +227 -0
  11. package/apps/web/app/(docs)/audit/audit.css +269 -0
  12. package/apps/web/app/(docs)/audit/page.tsx +271 -0
  13. package/apps/web/app/(docs)/components/button/page.tsx +49 -0
  14. package/apps/web/app/(docs)/components/form/page.tsx +92 -0
  15. package/apps/web/app/(docs)/components/link/page.tsx +31 -0
  16. package/apps/web/app/(docs)/components/modal/page.tsx +41 -0
  17. package/apps/web/app/(docs)/components/page.tsx +37 -0
  18. package/apps/web/app/(docs)/components/table/page.tsx +54 -0
  19. package/apps/web/app/(docs)/components/tabs/page.tsx +61 -0
  20. package/apps/web/app/(docs)/components/toast/page.tsx +51 -0
  21. package/apps/web/app/api/audit/route.ts +128 -0
  22. package/apps/web/app/favicon.ico +0 -0
  23. package/apps/web/app/layout.tsx +20 -0
  24. package/apps/web/app/page.tsx +17 -0
  25. package/apps/web/app/styles/globals.css +5 -0
  26. package/apps/web/next-env.d.ts +5 -0
  27. package/apps/web/next.config.js +21 -0
  28. package/apps/web/package.json +28 -0
  29. package/apps/web/public/_headers +17 -0
  30. package/apps/web/public/_redirects +31 -0
  31. package/apps/web/public/logo.png +0 -0
  32. package/apps/web/tsconfig.json +29 -0
  33. package/netlify/functions/audit.ts +163 -0
  34. package/netlify.toml +37 -0
  35. package/package.json +30 -58
  36. package/packages/design-system/README.md +252 -0
  37. package/packages/design-system/package.json +68 -0
  38. package/packages/design-system/scripts/copy-css.js +63 -0
  39. package/packages/design-system/src/components/Button/Button.stories.tsx +228 -0
  40. package/packages/design-system/src/components/Button/Button.tsx +137 -0
  41. package/packages/design-system/src/components/Button/index.ts +3 -0
  42. package/packages/design-system/src/components/DataTable/DataTable.stories.tsx +211 -0
  43. package/packages/design-system/src/components/DataTable/DataTable.tsx +293 -0
  44. package/packages/design-system/src/components/DataTable/index.ts +3 -0
  45. package/packages/design-system/src/components/Form/Checkbox.stories.tsx +252 -0
  46. package/packages/design-system/src/components/Form/Checkbox.tsx +114 -0
  47. package/packages/design-system/src/components/Form/Fieldset.stories.tsx +210 -0
  48. package/packages/design-system/src/components/Form/Fieldset.tsx +71 -0
  49. package/packages/design-system/src/components/Form/Input.stories.tsx +164 -0
  50. package/packages/design-system/src/components/Form/Input.tsx +113 -0
  51. package/packages/design-system/src/components/Form/Label.tsx +56 -0
  52. package/packages/design-system/src/components/Form/Radio.stories.tsx +265 -0
  53. package/packages/design-system/src/components/Form/Radio.tsx +147 -0
  54. package/packages/design-system/src/components/Form/Select.stories.tsx +295 -0
  55. package/packages/design-system/src/components/Form/Select.tsx +160 -0
  56. package/packages/design-system/src/components/Form/Textarea.stories.tsx +253 -0
  57. package/packages/design-system/src/components/Form/Textarea.tsx +145 -0
  58. package/packages/design-system/src/components/Form/index.ts +8 -0
  59. package/packages/design-system/src/components/Link/Link.stories.tsx +128 -0
  60. package/packages/design-system/src/components/Link/Link.tsx +117 -0
  61. package/packages/design-system/src/components/Link/index.ts +3 -0
  62. package/packages/design-system/src/components/Modal/Modal.stories.tsx +165 -0
  63. package/packages/design-system/src/components/Modal/Modal.tsx +202 -0
  64. package/packages/design-system/src/components/Modal/index.ts +3 -0
  65. package/packages/design-system/src/components/Tabs/Tabs.stories.tsx +213 -0
  66. package/packages/design-system/src/components/Tabs/Tabs.tsx +248 -0
  67. package/packages/design-system/src/components/Tabs/index.ts +3 -0
  68. package/packages/design-system/src/components/Toast/Toast.stories.tsx +153 -0
  69. package/packages/design-system/src/components/Toast/Toast.tsx +175 -0
  70. package/packages/design-system/src/components/Toast/ToastProvider.tsx +73 -0
  71. package/packages/design-system/src/components/Toast/index.ts +5 -0
  72. package/packages/design-system/src/hooks/useAriaLive.ts +51 -0
  73. package/packages/design-system/src/hooks/useFocusReturn.ts +40 -0
  74. package/packages/design-system/src/hooks/useFocusTrap.ts +82 -0
  75. package/{dist/index.js → packages/design-system/src/index.ts} +4 -0
  76. package/packages/design-system/src/styles/index.ts +3 -0
  77. package/packages/design-system/src/tokens/breakpoints.ts +28 -0
  78. package/packages/design-system/src/tokens/colors.ts +98 -0
  79. package/packages/design-system/src/tokens/index.ts +6 -0
  80. package/packages/design-system/src/tokens/motion.ts +41 -0
  81. package/packages/design-system/src/tokens/spacing.ts +24 -0
  82. package/packages/design-system/src/tokens/theme.ts +19 -0
  83. package/packages/design-system/src/tokens/typography.ts +64 -0
  84. package/packages/design-system/src/utils/aria.ts +108 -0
  85. package/packages/design-system/src/utils/focus.ts +87 -0
  86. package/packages/design-system/src/utils/index.ts +4 -0
  87. package/packages/design-system/src/utils/keyboard.ts +77 -0
  88. package/packages/design-system/tsconfig.json +17 -0
  89. package/public/logo.png +0 -0
  90. package/scripts/fix-storybook-paths.js +53 -0
  91. package/tsconfig.json +20 -0
  92. package/dist/components/Button/Button.d.ts +0 -37
  93. package/dist/components/Button/Button.d.ts.map +0 -1
  94. package/dist/components/Button/Button.js +0 -52
  95. package/dist/components/Button/index.d.ts +0 -3
  96. package/dist/components/Button/index.d.ts.map +0 -1
  97. package/dist/components/Button/index.js +0 -1
  98. package/dist/components/DataTable/DataTable.d.ts +0 -71
  99. package/dist/components/DataTable/DataTable.d.ts.map +0 -1
  100. package/dist/components/DataTable/DataTable.js +0 -122
  101. package/dist/components/DataTable/index.d.ts +0 -3
  102. package/dist/components/DataTable/index.d.ts.map +0 -1
  103. package/dist/components/DataTable/index.js +0 -1
  104. package/dist/components/Form/Checkbox.d.ts +0 -36
  105. package/dist/components/Form/Checkbox.d.ts.map +0 -1
  106. package/dist/components/Form/Checkbox.js +0 -39
  107. package/dist/components/Form/Fieldset.d.ts +0 -33
  108. package/dist/components/Form/Fieldset.d.ts.map +0 -1
  109. package/dist/components/Form/Fieldset.js +0 -34
  110. package/dist/components/Form/Input.d.ts +0 -37
  111. package/dist/components/Form/Input.d.ts.map +0 -1
  112. package/dist/components/Form/Input.js +0 -41
  113. package/dist/components/Form/Label.d.ts +0 -30
  114. package/dist/components/Form/Label.d.ts.map +0 -1
  115. package/dist/components/Form/Label.js +0 -30
  116. package/dist/components/Form/Radio.d.ts +0 -53
  117. package/dist/components/Form/Radio.d.ts.map +0 -1
  118. package/dist/components/Form/Radio.js +0 -39
  119. package/dist/components/Form/Select.d.ts +0 -51
  120. package/dist/components/Form/Select.d.ts.map +0 -1
  121. package/dist/components/Form/Select.js +0 -49
  122. package/dist/components/Form/Textarea.d.ts +0 -44
  123. package/dist/components/Form/Textarea.d.ts.map +0 -1
  124. package/dist/components/Form/Textarea.js +0 -43
  125. package/dist/components/Form/index.d.ts +0 -8
  126. package/dist/components/Form/index.d.ts.map +0 -1
  127. package/dist/components/Form/index.js +0 -7
  128. package/dist/components/Link/Link.d.ts +0 -34
  129. package/dist/components/Link/Link.d.ts.map +0 -1
  130. package/dist/components/Link/Link.js +0 -48
  131. package/dist/components/Link/index.d.ts +0 -3
  132. package/dist/components/Link/index.d.ts.map +0 -1
  133. package/dist/components/Link/index.js +0 -1
  134. package/dist/components/Modal/Modal.d.ts +0 -64
  135. package/dist/components/Modal/Modal.d.ts.map +0 -1
  136. package/dist/components/Modal/Modal.js +0 -108
  137. package/dist/components/Modal/index.d.ts +0 -3
  138. package/dist/components/Modal/index.d.ts.map +0 -1
  139. package/dist/components/Modal/index.js +0 -1
  140. package/dist/components/Tabs/Tabs.d.ts +0 -63
  141. package/dist/components/Tabs/Tabs.d.ts.map +0 -1
  142. package/dist/components/Tabs/Tabs.js +0 -134
  143. package/dist/components/Tabs/index.d.ts +0 -3
  144. package/dist/components/Tabs/index.d.ts.map +0 -1
  145. package/dist/components/Tabs/index.js +0 -1
  146. package/dist/components/Toast/Toast.d.ts +0 -59
  147. package/dist/components/Toast/Toast.d.ts.map +0 -1
  148. package/dist/components/Toast/Toast.js +0 -91
  149. package/dist/components/Toast/ToastProvider.d.ts +0 -22
  150. package/dist/components/Toast/ToastProvider.d.ts.map +0 -1
  151. package/dist/components/Toast/ToastProvider.js +0 -33
  152. package/dist/components/Toast/index.d.ts +0 -5
  153. package/dist/components/Toast/index.d.ts.map +0 -1
  154. package/dist/components/Toast/index.js +0 -2
  155. package/dist/hooks/useAriaLive.d.ts +0 -9
  156. package/dist/hooks/useAriaLive.d.ts.map +0 -1
  157. package/dist/hooks/useAriaLive.js +0 -39
  158. package/dist/hooks/useFocusReturn.d.ts +0 -9
  159. package/dist/hooks/useFocusReturn.d.ts.map +0 -1
  160. package/dist/hooks/useFocusReturn.js +0 -33
  161. package/dist/hooks/useFocusTrap.d.ts +0 -9
  162. package/dist/hooks/useFocusTrap.d.ts.map +0 -1
  163. package/dist/hooks/useFocusTrap.js +0 -68
  164. package/dist/index.d.ts +0 -22
  165. package/dist/index.d.ts.map +0 -1
  166. package/dist/styles/index.d.ts +0 -3
  167. package/dist/styles/index.d.ts.map +0 -1
  168. package/dist/styles/index.js +0 -1
  169. package/dist/tokens/breakpoints.d.ts +0 -25
  170. package/dist/tokens/breakpoints.d.ts.map +0 -1
  171. package/dist/tokens/breakpoints.js +0 -23
  172. package/dist/tokens/colors.d.ts +0 -81
  173. package/dist/tokens/colors.d.ts.map +0 -1
  174. package/dist/tokens/colors.js +0 -86
  175. package/dist/tokens/index.d.ts +0 -6
  176. package/dist/tokens/index.d.ts.map +0 -1
  177. package/dist/tokens/index.js +0 -5
  178. package/dist/tokens/motion.d.ts +0 -30
  179. package/dist/tokens/motion.d.ts.map +0 -1
  180. package/dist/tokens/motion.js +0 -34
  181. package/dist/tokens/spacing.d.ts +0 -22
  182. package/dist/tokens/spacing.d.ts.map +0 -1
  183. package/dist/tokens/spacing.js +0 -20
  184. package/dist/tokens/theme.d.ts +0 -159
  185. package/dist/tokens/theme.d.ts.map +0 -1
  186. package/dist/tokens/theme.js +0 -15
  187. package/dist/tokens/typography.d.ts +0 -45
  188. package/dist/tokens/typography.d.ts.map +0 -1
  189. package/dist/tokens/typography.js +0 -56
  190. package/dist/utils/aria.d.ts +0 -60
  191. package/dist/utils/aria.d.ts.map +0 -1
  192. package/dist/utils/aria.js +0 -86
  193. package/dist/utils/focus.d.ts +0 -30
  194. package/dist/utils/focus.d.ts.map +0 -1
  195. package/dist/utils/focus.js +0 -80
  196. package/dist/utils/index.d.ts +0 -4
  197. package/dist/utils/index.d.ts.map +0 -1
  198. package/dist/utils/index.js +0 -3
  199. package/dist/utils/keyboard.d.ts +0 -38
  200. package/dist/utils/keyboard.d.ts.map +0 -1
  201. package/dist/utils/keyboard.js +0 -59
  202. /package/{dist → packages/design-system/src}/components/Button/Button.css +0 -0
  203. /package/{dist → packages/design-system/src}/components/DataTable/DataTable.css +0 -0
  204. /package/{dist → packages/design-system/src}/components/Form/Checkbox.css +0 -0
  205. /package/{dist → packages/design-system/src}/components/Form/Fieldset.css +0 -0
  206. /package/{dist → packages/design-system/src}/components/Form/Input.css +0 -0
  207. /package/{dist → packages/design-system/src}/components/Form/Label.css +0 -0
  208. /package/{dist → packages/design-system/src}/components/Form/Radio.css +0 -0
  209. /package/{dist → packages/design-system/src}/components/Form/Select.css +0 -0
  210. /package/{dist → packages/design-system/src}/components/Form/Textarea.css +0 -0
  211. /package/{dist → packages/design-system/src}/components/Link/Link.css +0 -0
  212. /package/{dist → packages/design-system/src}/components/Modal/Modal.css +0 -0
  213. /package/{dist → packages/design-system/src}/components/Tabs/Tabs.css +0 -0
  214. /package/{dist → packages/design-system/src}/components/Toast/Toast.css +0 -0
  215. /package/{dist → packages/design-system/src}/components/Toast/ToastProvider.css +0 -0
  216. /package/{dist → packages/design-system/src}/styles/components.css +0 -0
  217. /package/{dist → packages/design-system/src}/styles/global.css +0 -0
@@ -0,0 +1,160 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+ import { combineAriaDescribedBy } from '../../utils/aria'
5
+ import { createArrowKeyHandler, isActivationKey } from '../../utils/keyboard'
6
+ import './Select.css'
7
+
8
+ export interface SelectOption {
9
+ value: string
10
+ label: string
11
+ disabled?: boolean
12
+ }
13
+
14
+ export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
15
+ /**
16
+ * Options for the select
17
+ */
18
+ options: SelectOption[]
19
+
20
+ /**
21
+ * Error message to display
22
+ */
23
+ error?: string
24
+
25
+ /**
26
+ * Helper text to display below the select
27
+ */
28
+ helperText?: string
29
+
30
+ /**
31
+ * Label for the select
32
+ */
33
+ label?: string
34
+
35
+ /**
36
+ * Placeholder option text
37
+ */
38
+ placeholder?: string
39
+ }
40
+
41
+ /**
42
+ * Accessible Select component
43
+ *
44
+ * WCAG Compliance:
45
+ * - 1.3.1 Info and Relationships: Proper label-select association
46
+ * - 2.1.1 Keyboard: Full keyboard navigation
47
+ * - 4.1.2 Name, Role, Value: Proper ARIA attributes
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * <Select
52
+ * id="country"
53
+ * label="Country"
54
+ * options={[
55
+ * { value: 'us', label: 'United States' },
56
+ * { value: 'ca', label: 'Canada' },
57
+ * ]}
58
+ * />
59
+ * ```
60
+ */
61
+ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
62
+ (
63
+ {
64
+ id,
65
+ options,
66
+ error,
67
+ helperText,
68
+ label,
69
+ placeholder,
70
+ className = '',
71
+ 'aria-describedby': ariaDescribedBy,
72
+ ...props
73
+ },
74
+ ref
75
+ ) => {
76
+ const selectId = React.useId()
77
+ const finalId = id || `select-${selectId}`
78
+ const errorId = error ? `${finalId}-error` : undefined
79
+ const helperId = helperText ? `${finalId}-helper` : undefined
80
+
81
+ const describedBy = combineAriaDescribedBy(
82
+ ariaDescribedBy,
83
+ errorId,
84
+ helperId
85
+ )
86
+
87
+ const handleKeyDown = React.useCallback(
88
+ (event: React.KeyboardEvent<HTMLSelectElement>) => {
89
+ // Arrow keys are handled natively by select
90
+ // But we can add custom handling if needed
91
+ if (props.onKeyDown) {
92
+ props.onKeyDown(event)
93
+ }
94
+ },
95
+ [props.onKeyDown]
96
+ )
97
+
98
+ const classes = [
99
+ 'form-select',
100
+ error && 'form-select--error',
101
+ props.disabled && 'form-select--disabled',
102
+ className,
103
+ ]
104
+ .filter(Boolean)
105
+ .join(' ')
106
+
107
+ return (
108
+ <div className="form-select-wrapper">
109
+ {label && (
110
+ <label htmlFor={finalId} className="form-label">
111
+ {label}
112
+ {props.required && (
113
+ <span className="form-label__required" aria-hidden="true">
114
+ {' '}*
115
+ </span>
116
+ )}
117
+ </label>
118
+ )}
119
+ <select
120
+ ref={ref}
121
+ id={finalId}
122
+ className={classes}
123
+ aria-invalid={error ? true : undefined}
124
+ aria-describedby={describedBy}
125
+ onKeyDown={handleKeyDown}
126
+ required={props.required ? true : undefined}
127
+ {...props}
128
+ >
129
+ {placeholder && (
130
+ <option value="" disabled>
131
+ {placeholder}
132
+ </option>
133
+ )}
134
+ {options.map((option) => (
135
+ <option
136
+ key={option.value}
137
+ value={option.value}
138
+ disabled={option.disabled}
139
+ >
140
+ {option.label}
141
+ </option>
142
+ ))}
143
+ </select>
144
+ {helperText && !error && (
145
+ <span id={helperId} className="form-helper-text">
146
+ {helperText}
147
+ </span>
148
+ )}
149
+ {error && (
150
+ <span id={errorId} className="form-error-text" role="alert">
151
+ {error}
152
+ </span>
153
+ )}
154
+ </div>
155
+ )
156
+ }
157
+ )
158
+
159
+ Select.displayName = 'Select'
160
+
@@ -0,0 +1,253 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { useState } from 'react'
3
+ import { Textarea } from './Textarea'
4
+
5
+ /**
6
+ * # Textarea Component
7
+ *
8
+ * An accessible textarea component with proper label association, error handling,
9
+ * character counting, and ARIA attributes. Ideal for multi-line text input.
10
+ *
11
+ * ## Usage
12
+ *
13
+ * ```tsx
14
+ * import { Textarea } from '@a11ypros/a11y-ui-components'
15
+ *
16
+ * function MyComponent() {
17
+ * return (
18
+ * <Textarea
19
+ * label="Message"
20
+ * placeholder="Enter your message"
21
+ * maxLength={500}
22
+ * showCount
23
+ * />
24
+ * )
25
+ * }
26
+ * ```
27
+ *
28
+ * ## Features
29
+ *
30
+ * - **Label association**: Labels are properly associated with textareas using \`htmlFor\` and \`id\`
31
+ * - **Error handling**: Error messages are announced to screen readers via ARIA
32
+ * - **Helper text**: Optional helper text for additional context
33
+ * - **Character counting**: Optional character count display with \`maxLength\` support
34
+ * - **Auto-resize**: Can be configured to grow with content
35
+ *
36
+ * ## Accessibility
37
+ *
38
+ * ### WCAG 2.1/2.2 Compliance
39
+ *
40
+ * - **1.3.1 Info and Relationships**: Proper label-textarea association via \`htmlFor\` and \`id\`
41
+ * - **4.1.2 Name, Role, Value**: Proper ARIA attributes including \`aria-required\`, \`aria-invalid\`, \`aria-describedby\`
42
+ * - **4.1.3 Status Messages**: Error messages and character count announced to screen readers
43
+ *
44
+ * ### Keyboard Interactions
45
+ *
46
+ * | Key | Action |
47
+ * |-----|--------|
48
+ * | **Tab** | Moves focus to the textarea |
49
+ * | **Shift+Tab** | Moves focus away from the textarea |
50
+ * | **Enter** | Creates new line (does not submit form) |
51
+ * | **Arrow keys** | Navigate within text content |
52
+ *
53
+ * ### Screen Reader Support
54
+ *
55
+ * - Textarea role and label are announced when focused
56
+ * - Character count is announced when \`showCount\` is enabled
57
+ * - Required state is announced ("required")
58
+ * - Error messages are announced when present
59
+ * - Helper text is announced via \`aria-describedby\`
60
+ * - Max length is announced when \`maxLength\` is set
61
+ *
62
+ * ### Focus Management
63
+ *
64
+ * - Focus indicators use 2px solid outline with 2px offset
65
+ * - Focus styles respect \`prefers-reduced-motion\` media query
66
+ * - Error state has distinct focus styling
67
+ * - Focus visible only on keyboard navigation using \`:focus-visible\`
68
+ *
69
+ * ## Best Practices
70
+ *
71
+ * 1. **Always provide a label**: Required for accessibility and usability
72
+ * 2. **Use for multi-line input**: Textarea is for longer, multi-line text (use Input for single-line)
73
+ * 3. **Set appropriate maxLength**: Helps users understand constraints
74
+ * 4. **Provide helpful error messages**: Be specific about validation failures
75
+ * 5. **Use helper text for guidance**: Help users understand what to enter
76
+ *
77
+ * ## Common Pitfalls
78
+ *
79
+ * - Missing label (screen readers can't identify the textarea)
80
+ * - Using textarea for single-line input (use Input component instead)
81
+ * - Not setting maxLength when needed (users don't know limits)
82
+ * - Vague error messages (be specific about validation failures)
83
+ * - Too small initial size (hard to see what you're typing)
84
+ *
85
+ * @component
86
+ * @example
87
+ * ```tsx
88
+ * <Textarea
89
+ * label="Feedback"
90
+ * maxLength={500}
91
+ * showCount
92
+ * required
93
+ * error={errors.feedback}
94
+ * helperText="Please provide detailed feedback"
95
+ * />
96
+ * ```
97
+ */
98
+ const meta: Meta<typeof Textarea> = {
99
+ title: 'Components/Form/Textarea',
100
+ component: Textarea,
101
+ tags: ['autodocs'],
102
+ }
103
+
104
+ export default meta
105
+ type Story = StoryObj<typeof Textarea>
106
+
107
+ /**
108
+ * Standard textarea with label and placeholder. The label is properly associated
109
+ * with the textarea for screen readers and clicking the label focuses the textarea.
110
+ */
111
+ export const Default: Story = {
112
+ render: () => {
113
+ const [value, setValue] = useState('')
114
+ return (
115
+ <Textarea
116
+ id="textarea-default"
117
+ label="Message"
118
+ value={value}
119
+ onChange={(e) => setValue(e.target.value)}
120
+ placeholder="Enter your message here..."
121
+ />
122
+ )
123
+ },
124
+ }
125
+
126
+ /**
127
+ * Textarea with character count. Shows remaining characters when \`maxLength\`
128
+ * and \`showCount\` are set. The count is announced to screen readers.
129
+ */
130
+ export const WithCharacterCount: Story = {
131
+ render: () => {
132
+ const [value, setValue] = useState('')
133
+ return (
134
+ <Textarea
135
+ id="textarea-count"
136
+ label="Description"
137
+ value={value}
138
+ onChange={(e) => setValue(e.target.value)}
139
+ maxLength={200}
140
+ showCount
141
+ placeholder="Enter a description (max 200 characters)"
142
+ />
143
+ )
144
+ },
145
+ }
146
+
147
+ /**
148
+ * Textarea with error message. Error messages are announced to screen readers
149
+ * and displayed visually below the textarea. The textarea is marked as invalid
150
+ * with \`aria-invalid="true"\`.
151
+ */
152
+ export const WithError: Story = {
153
+ render: () => {
154
+ const [value, setValue] = useState('')
155
+ return (
156
+ <Textarea
157
+ id="textarea-error"
158
+ label="Comments"
159
+ value={value}
160
+ onChange={(e) => setValue(e.target.value)}
161
+ error="Please enter at least 10 characters"
162
+ maxLength={500}
163
+ showCount
164
+ />
165
+ )
166
+ },
167
+ }
168
+
169
+ /**
170
+ * Textarea with helper text. Helper text provides additional context or guidance
171
+ * and is associated with the textarea via \`aria-describedby\`.
172
+ */
173
+ export const WithHelperText: Story = {
174
+ render: () => {
175
+ const [value, setValue] = useState('')
176
+ return (
177
+ <Textarea
178
+ id="textarea-helper"
179
+ label="Additional notes"
180
+ value={value}
181
+ onChange={(e) => setValue(e.target.value)}
182
+ helperText="Include any additional information that might be helpful"
183
+ maxLength={1000}
184
+ showCount
185
+ />
186
+ )
187
+ },
188
+ }
189
+
190
+ /**
191
+ * Required textarea field. Required textareas are marked with \`aria-required="true"\`
192
+ * and display a visual indicator (asterisk) next to the label.
193
+ */
194
+ export const Required: Story = {
195
+ render: () => {
196
+ const [value, setValue] = useState('')
197
+ return (
198
+ <Textarea
199
+ id="textarea-required"
200
+ label="Bio"
201
+ value={value}
202
+ onChange={(e) => setValue(e.target.value)}
203
+ required
204
+ maxLength={500}
205
+ showCount
206
+ placeholder="Tell us about yourself"
207
+ />
208
+ )
209
+ },
210
+ }
211
+
212
+ /**
213
+ * Disabled textarea that cannot be edited. Disabled textareas are announced as
214
+ * "disabled" by screen readers and appear visually dimmed.
215
+ */
216
+ export const Disabled: Story = {
217
+ args: {
218
+ id: 'textarea-disabled',
219
+ label: 'Disabled textarea',
220
+ disabled: true,
221
+ defaultValue: 'This textarea cannot be edited',
222
+ },
223
+ }
224
+
225
+ /**
226
+ * Large textarea for longer content. Use appropriate rows and cols attributes
227
+ * to provide a good initial size for the content type.
228
+ */
229
+ export const Large: Story = {
230
+ render: () => {
231
+ const [value, setValue] = useState('')
232
+ return (
233
+ <Textarea
234
+ id="textarea-large"
235
+ label="Full article"
236
+ value={value}
237
+ onChange={(e) => setValue(e.target.value)}
238
+ rows={10}
239
+ maxLength={5000}
240
+ showCount
241
+ placeholder="Write your article here..."
242
+ />
243
+ )
244
+ },
245
+ parameters: {
246
+ docs: {
247
+ description: {
248
+ story: 'Large textarea with more rows for longer content. Adjust rows and cols to fit your use case.',
249
+ },
250
+ },
251
+ },
252
+ }
253
+
@@ -0,0 +1,145 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+ import { combineAriaDescribedBy } from '../../utils/aria'
5
+ import './Textarea.css'
6
+
7
+ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
8
+ /**
9
+ * Error message to display
10
+ */
11
+ error?: string
12
+
13
+ /**
14
+ * Helper text to display below the textarea
15
+ */
16
+ helperText?: string
17
+
18
+ /**
19
+ * Label for the textarea
20
+ */
21
+ label?: string
22
+
23
+ /**
24
+ * Maximum character count (shows counter)
25
+ */
26
+ maxLength?: number
27
+
28
+ /**
29
+ * Whether to show character count
30
+ */
31
+ showCount?: boolean
32
+ }
33
+
34
+ /**
35
+ * Accessible Textarea component
36
+ *
37
+ * WCAG Compliance:
38
+ * - 1.3.1 Info and Relationships: Proper label-textarea association
39
+ * - 4.1.2 Name, Role, Value: Proper ARIA attributes
40
+ * - 4.1.3 Status Messages: Error messages announced
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * <Textarea
45
+ * id="message"
46
+ * label="Message"
47
+ * maxLength={500}
48
+ * showCount
49
+ * />
50
+ * ```
51
+ */
52
+ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
53
+ (
54
+ {
55
+ id,
56
+ error,
57
+ helperText,
58
+ label,
59
+ maxLength,
60
+ showCount = false,
61
+ className = '',
62
+ value,
63
+ 'aria-describedby': ariaDescribedBy,
64
+ ...props
65
+ },
66
+ ref
67
+ ) => {
68
+ const textareaId = React.useId()
69
+ const finalId = id || `textarea-${textareaId}`
70
+ const errorId = error ? `${finalId}-error` : undefined
71
+ const helperId = helperText ? `${finalId}-helper` : undefined
72
+ const countId = showCount ? `${finalId}-count` : undefined
73
+
74
+ const describedBy = combineAriaDescribedBy(
75
+ ariaDescribedBy,
76
+ errorId,
77
+ helperId,
78
+ countId
79
+ )
80
+
81
+ const currentLength = typeof value === 'string' ? value.length : 0
82
+ const remainingChars = maxLength ? maxLength - currentLength : undefined
83
+
84
+ const classes = [
85
+ 'form-textarea',
86
+ error && 'form-textarea--error',
87
+ props.disabled && 'form-textarea--disabled',
88
+ className,
89
+ ]
90
+ .filter(Boolean)
91
+ .join(' ')
92
+
93
+ return (
94
+ <div className="form-textarea-wrapper">
95
+ {label && (
96
+ <label htmlFor={finalId} className="form-label">
97
+ {label}
98
+ {props.required && (
99
+ <span className="form-label__required" aria-hidden="true">
100
+ {' '}*
101
+ </span>
102
+ )}
103
+ </label>
104
+ )}
105
+ <textarea
106
+ ref={ref}
107
+ id={finalId}
108
+ className={classes}
109
+ maxLength={maxLength}
110
+ value={value}
111
+ aria-invalid={error ? true : undefined}
112
+ aria-describedby={describedBy}
113
+ required={props.required ? true : undefined}
114
+ {...props}
115
+ />
116
+ {(showCount || helperText) && (
117
+ <div className="form-textarea-footer">
118
+ {helperText && !error && (
119
+ <span id={helperId} className="form-helper-text">
120
+ {helperText}
121
+ </span>
122
+ )}
123
+ {error && (
124
+ <span id={errorId} className="form-error-text" role="alert">
125
+ {error}
126
+ </span>
127
+ )}
128
+ {showCount && maxLength && (
129
+ <span
130
+ id={countId}
131
+ className="form-character-count"
132
+ aria-live="polite"
133
+ >
134
+ {currentLength} / {maxLength}
135
+ </span>
136
+ )}
137
+ </div>
138
+ )}
139
+ </div>
140
+ )
141
+ }
142
+ )
143
+
144
+ Textarea.displayName = 'Textarea'
145
+
@@ -0,0 +1,8 @@
1
+ export * from './Input'
2
+ export * from './Textarea'
3
+ export * from './Select'
4
+ export * from './Checkbox'
5
+ export * from './Radio'
6
+ export * from './Fieldset'
7
+ export * from './Label'
8
+
@@ -0,0 +1,128 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { Link } from './Link'
3
+
4
+ /**
5
+ * # Link Component
6
+ *
7
+ * An accessible link component with automatic external link detection, skip link support,
8
+ * and proper focus management. Uses semantic `<a>` element with enhanced accessibility features.
9
+ *
10
+ * ## Usage
11
+ *
12
+ * ```tsx
13
+ * import { Link } from '@a11ypros/a11y-ui-components'
14
+ *
15
+ * function MyComponent() {
16
+ * return (
17
+ * <Link href="/about">
18
+ * Learn more about us
19
+ * </Link>
20
+ * )
21
+ * }
22
+ * ```
23
+ *
24
+ * ## Features
25
+ *
26
+ * - **External link detection**: Automatically adds \`rel="noopener noreferrer"\` for security
27
+ * - **Skip links**: Special styling and behavior for keyboard navigation skip links
28
+ * - **Focus management**: Clear focus indicators that meet WCAG contrast requirements
29
+ * - **ARIA support**: Proper aria-label support for icon-only or context-dependent links
30
+ *
31
+ * ## Accessibility
32
+ *
33
+ * ### WCAG 2.1/2.2 Compliance
34
+ *
35
+ * - **2.4.4 Link Purpose (In Context)**: Clear link text or aria-label that describes the link purpose
36
+ * - **2.4.7 Focus Visible**: Clear 2px outline on focus (meets 3:1 contrast ratio)
37
+ * - **4.1.2 Name, Role, Value**: Proper semantic HTML with accessible name
38
+ *
39
+ * ### Keyboard Interactions
40
+ *
41
+ * | Key | Action |
42
+ * |-----|--------|
43
+ * | **Enter** | Activates the link |
44
+ * | **Tab** | Moves focus to the link |
45
+ * | **Shift+Tab** | Moves focus away from the link |
46
+ *
47
+ * ### Screen Reader Support
48
+ *
49
+ * - Link role and name are announced automatically
50
+ * - External links: "Link, external" announced (when \`external\` prop is true)
51
+ * - Skip links: Properly announced for keyboard navigation
52
+ * - Custom labels via \`aria-label\` prop for context-dependent links
53
+ *
54
+ * ### Focus Management
55
+ *
56
+ * - Focus indicators use 2px solid outline with 2px offset
57
+ * - Focus styles respect \`prefers-reduced-motion\` media query
58
+ * - High contrast mode supported via \`prefers-contrast\` media query
59
+ * - Focus visible only on keyboard navigation (not mouse clicks) using \`:focus-visible\`
60
+ *
61
+ * ## Best Practices
62
+ *
63
+ * 1. **Use descriptive link text**: Avoid "click here" or "read more" - describe what the link does
64
+ * 2. **Use external prop for external links**: Automatically adds security attributes
65
+ * 3. **Provide aria-label for icon-only links**: Screen readers need context
66
+ * 4. **Use skip links for main content**: Helps keyboard users navigate large pages
67
+ * 5. **Don't use links for buttons**: Use Button component for actions, Link for navigation
68
+ *
69
+ * ## Common Pitfalls
70
+ *
71
+ * - Using generic link text like "click here" (screen readers lose context)
72
+ * - Missing \`external\` prop on external links (security risk)
73
+ * - Using links for actions instead of navigation (use Button component)
74
+ * - Missing \`aria-label\` on icon-only links (screen readers can't understand purpose)
75
+ * - Removing focus styles (breaks keyboard navigation visibility)
76
+ *
77
+ * @component
78
+ * @example
79
+ * ```tsx
80
+ * <Link href="/about" external>
81
+ * Learn more
82
+ * </Link>
83
+ * ```
84
+ */
85
+ const meta: Meta<typeof Link> = {
86
+ title: 'Components/Link',
87
+ component: Link,
88
+ tags: ['autodocs'],
89
+ }
90
+
91
+ export default meta
92
+ type Story = StoryObj<typeof Link>
93
+
94
+ /**
95
+ * Standard internal link for navigation within the application.
96
+ */
97
+ export const Default: Story = {
98
+ args: {
99
+ href: '#',
100
+ children: 'Link text',
101
+ },
102
+ }
103
+
104
+ /**
105
+ * External link that automatically adds security attributes (\`rel="noopener noreferrer"\`)
106
+ * to prevent security vulnerabilities when opening links in new tabs.
107
+ */
108
+ export const External: Story = {
109
+ args: {
110
+ href: 'https://example.com',
111
+ external: true,
112
+ children: 'External link',
113
+ },
114
+ }
115
+
116
+ /**
117
+ * Skip link for keyboard navigation. These links allow keyboard users to skip
118
+ * repetitive navigation and jump directly to main content. Typically placed
119
+ * at the top of the page and styled to be visible on focus.
120
+ */
121
+ export const SkipLink: Story = {
122
+ args: {
123
+ href: '#main-content',
124
+ skip: true,
125
+ children: 'Skip to main content',
126
+ },
127
+ }
128
+