@fpkit/acss 0.5.13 → 0.6.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.
- package/libs/{chunk-PQ2K3BM6.cjs → chunk-2NRIP6RB.cjs} +3 -3
- package/libs/chunk-33PNJ4LO.cjs +15 -0
- package/libs/chunk-33PNJ4LO.cjs.map +1 -0
- package/libs/chunk-4BZKFPEC.cjs +17 -0
- package/libs/chunk-4BZKFPEC.cjs.map +1 -0
- package/libs/{chunk-772NRB75.js → chunk-5QD3DWFI.js} +2 -2
- package/libs/chunk-6SAHIYCZ.js +7 -0
- package/libs/chunk-6SAHIYCZ.js.map +1 -0
- package/libs/{chunk-3MKLDCKQ.cjs → chunk-6WTC4JXH.cjs} +3 -3
- package/libs/chunk-75QHTLFO.js +7 -0
- package/libs/chunk-75QHTLFO.js.map +1 -0
- package/libs/{chunk-ZANSFMTD.js → chunk-7XPFW7CB.js} +3 -3
- package/libs/chunk-BFK62VX5.js +5 -0
- package/libs/chunk-BFK62VX5.js.map +1 -0
- package/libs/{chunk-ROZI23GS.cjs → chunk-DKTHCQ5P.cjs} +4 -4
- package/libs/chunk-E2AJURUW.cjs +13 -0
- package/libs/chunk-E2AJURUW.cjs.map +1 -0
- package/libs/{chunk-L75OQKEI.cjs → chunk-ENTCUJ3A.cjs} +3 -3
- package/libs/chunk-ENTCUJ3A.cjs.map +1 -0
- package/libs/chunk-F5EYMVQM.js +10 -0
- package/libs/chunk-F5EYMVQM.js.map +1 -0
- package/libs/chunk-FVROL3V5.js +9 -0
- package/libs/chunk-FVROL3V5.js.map +1 -0
- package/libs/chunk-GT77BX4L.cjs +17 -0
- package/libs/chunk-GT77BX4L.cjs.map +1 -0
- package/libs/chunk-GUJSMQ3V.cjs +16 -0
- package/libs/chunk-GUJSMQ3V.cjs.map +1 -0
- package/libs/chunk-HHLNOC5T.js +7 -0
- package/libs/chunk-HHLNOC5T.js.map +1 -0
- package/libs/chunk-HRRHPLER.js +8 -0
- package/libs/chunk-HRRHPLER.js.map +1 -0
- package/libs/chunk-IEB64SWY.js +8 -0
- package/libs/chunk-IEB64SWY.js.map +1 -0
- package/libs/{chunk-NGTJDDFO.js → chunk-IQ76HGVP.js} +2 -2
- package/libs/chunk-IRLFZ3OL.js +9 -0
- package/libs/chunk-IRLFZ3OL.js.map +1 -0
- package/libs/{chunk-JJ43O4Y5.js → chunk-KK47SYZI.js} +2 -2
- package/libs/chunk-O3JIHC5M.cjs +15 -0
- package/libs/chunk-O3JIHC5M.cjs.map +1 -0
- package/libs/chunk-O5XAJ7BY.cjs +18 -0
- package/libs/chunk-O5XAJ7BY.cjs.map +1 -0
- package/libs/chunk-OVWLQYMK.js +10 -0
- package/libs/chunk-OVWLQYMK.js.map +1 -0
- package/libs/chunk-PNWIRCG3.cjs +7 -0
- package/libs/chunk-PNWIRCG3.cjs.map +1 -0
- package/libs/{chunk-D4YLRWAO.cjs → chunk-QVW6W76L.cjs} +6 -6
- package/libs/chunk-T4T6GWYQ.cjs +17 -0
- package/libs/chunk-T4T6GWYQ.cjs.map +1 -0
- package/libs/chunk-TON2YGMD.cjs +9 -0
- package/libs/chunk-TON2YGMD.cjs.map +1 -0
- package/libs/chunk-UEPAWMDF.js +8 -0
- package/libs/chunk-UEPAWMDF.js.map +1 -0
- package/libs/{chunk-LT5KZ2QW.cjs → chunk-US2I5GI7.cjs} +3 -3
- package/libs/{chunk-B7F5FS6D.cjs → chunk-W2UIN7EV.cjs} +3 -3
- package/libs/{chunk-P2DC76ZZ.cjs → chunk-W5TKWBFC.cjs} +3 -3
- package/libs/chunk-WXBFBWYF.cjs +16 -0
- package/libs/chunk-WXBFBWYF.cjs.map +1 -0
- package/libs/{chunk-VUH3FXGJ.js → chunk-X3JCTEPD.js} +5 -5
- package/libs/chunk-X5LGFCWG.js +9 -0
- package/libs/chunk-X5LGFCWG.js.map +1 -0
- package/libs/{chunk-5M57K4SW.js → chunk-Y2PFDELK.js} +2 -2
- package/libs/{chunk-ETFLFC2S.js → chunk-ZFJ4U45S.js} +2 -2
- package/libs/{component-props-a8a2f97e.d.ts → component-props-67d978a2.d.ts} +4 -4
- package/libs/components/alert/alert.css +1 -1
- package/libs/components/alert/alert.css.map +1 -1
- package/libs/components/alert/alert.min.css +2 -2
- package/libs/components/breadcrumbs/breadcrumb.cjs +6 -6
- package/libs/components/breadcrumbs/breadcrumb.d.cts +11 -11
- package/libs/components/breadcrumbs/breadcrumb.d.ts +11 -11
- package/libs/components/breadcrumbs/breadcrumb.js +3 -3
- package/libs/components/button.cjs +6 -4
- package/libs/components/button.d.cts +97 -4
- package/libs/components/button.d.ts +97 -4
- package/libs/components/button.js +4 -2
- package/libs/components/card.cjs +7 -7
- package/libs/components/card.d.cts +14 -14
- package/libs/components/card.d.ts +14 -14
- package/libs/components/card.js +2 -2
- package/libs/components/dialog/dialog.cjs +9 -7
- package/libs/components/dialog/dialog.d.cts +3 -3
- package/libs/components/dialog/dialog.d.ts +3 -3
- package/libs/components/dialog/dialog.js +7 -5
- package/libs/components/form/fields.cjs +4 -4
- package/libs/components/form/fields.d.cts +16 -7
- package/libs/components/form/fields.d.ts +16 -7
- package/libs/components/form/fields.js +2 -2
- package/libs/components/form/inputs.cjs +6 -4
- package/libs/components/form/inputs.d.cts +50 -2
- package/libs/components/form/inputs.d.ts +50 -2
- package/libs/components/form/inputs.js +4 -2
- package/libs/components/form/textarea.cjs +5 -4
- package/libs/components/form/textarea.d.cts +32 -23
- package/libs/components/form/textarea.d.ts +32 -23
- package/libs/components/form/textarea.js +3 -2
- package/libs/components/heading/heading.cjs +3 -3
- package/libs/components/heading/heading.d.cts +2 -2
- package/libs/components/heading/heading.d.ts +2 -2
- package/libs/components/heading/heading.js +2 -2
- package/libs/components/icons/icon.cjs +4 -4
- package/libs/components/icons/icon.d.cts +38 -38
- package/libs/components/icons/icon.d.ts +38 -38
- package/libs/components/icons/icon.js +2 -2
- package/libs/components/link/link.cjs +4 -4
- package/libs/components/link/link.css +1 -1
- package/libs/components/link/link.css.map +1 -1
- package/libs/components/link/link.d.cts +3 -19
- package/libs/components/link/link.d.ts +3 -19
- package/libs/components/link/link.js +2 -2
- package/libs/components/link/link.min.css +2 -2
- package/libs/components/list/list.cjs +5 -5
- package/libs/components/list/list.css +1 -0
- package/libs/components/list/list.css.map +1 -0
- package/libs/components/list/list.d.cts +120 -33
- package/libs/components/list/list.d.ts +120 -33
- package/libs/components/list/list.js +2 -2
- package/libs/components/list/list.min.css +3 -0
- package/libs/components/modal.cjs +6 -4
- package/libs/components/modal.d.cts +8 -8
- package/libs/components/modal.d.ts +8 -8
- package/libs/components/modal.js +5 -3
- package/libs/components/nav/nav.cjs +7 -7
- package/libs/components/nav/nav.css +1 -1
- package/libs/components/nav/nav.css.map +1 -1
- package/libs/components/nav/nav.d.cts +550 -34
- package/libs/components/nav/nav.d.ts +550 -34
- package/libs/components/nav/nav.js +3 -3
- package/libs/components/nav/nav.min.css +2 -2
- package/libs/components/popover/popover.d.cts +5 -5
- package/libs/components/popover/popover.d.ts +5 -5
- package/libs/components/tables/table.cjs +5 -5
- package/libs/components/tables/table.d.cts +8 -8
- package/libs/components/tables/table.d.ts +8 -8
- package/libs/components/tables/table.js +2 -2
- package/libs/components/tag/tag.css +1 -1
- package/libs/components/tag/tag.css.map +1 -1
- package/libs/components/tag/tag.min.css +2 -2
- package/libs/components/text/text.cjs +5 -5
- package/libs/components/text/text.d.cts +5 -5
- package/libs/components/text/text.d.ts +5 -5
- package/libs/components/text/text.js +2 -2
- package/libs/form.types-d25ebfac.d.ts +233 -0
- package/libs/{heading-3648c538.d.ts → heading-7446cb46.d.ts} +8 -8
- package/libs/hooks.cjs +9 -4
- package/libs/hooks.d.cts +137 -3
- package/libs/hooks.d.ts +137 -3
- package/libs/hooks.js +4 -3
- package/libs/icons.cjs +3 -3
- package/libs/icons.d.cts +2 -2
- package/libs/icons.d.ts +2 -2
- package/libs/icons.js +2 -2
- package/libs/index.cjs +53 -51
- package/libs/index.cjs.map +1 -1
- package/libs/index.css +1 -1
- package/libs/index.css.map +1 -1
- package/libs/index.d.cts +338 -49
- package/libs/index.d.ts +338 -49
- package/libs/index.js +24 -22
- package/libs/index.js.map +1 -1
- package/libs/link-5192f411.d.ts +323 -0
- package/libs/list.types-d26de310.d.ts +245 -0
- package/libs/{ui-645f95b5.d.ts → ui-d01b50d4.d.ts} +16 -12
- package/package.json +4 -6
- package/src/components/alert/alert.scss +1 -4
- package/src/components/breadcrumbs/breadcrumb.tsx +4 -1
- package/src/components/buttons/README.mdx +102 -1
- package/src/components/buttons/button.stories.tsx +106 -0
- package/src/components/buttons/button.tsx +82 -52
- package/src/components/dialog/dialog-a11y-review.md +653 -0
- package/src/components/form/README.mdx +725 -43
- package/src/components/form/WCAG-REVIEW.md +654 -0
- package/src/components/form/fields.tsx +10 -1
- package/src/components/form/form.stories.tsx +604 -23
- package/src/components/form/form.tsx +204 -63
- package/src/components/form/form.types.ts +378 -0
- package/src/components/form/input.stories.tsx +71 -3
- package/src/components/form/inputs.tsx +159 -67
- package/src/components/form/select.tsx +122 -66
- package/src/components/form/textarea.tsx +120 -73
- package/src/components/fp.tsx +86 -11
- package/src/components/link/README.mdx +923 -0
- package/src/components/link/link.scss +79 -26
- package/src/components/link/link.stories.tsx +383 -30
- package/src/components/link/link.test.tsx +677 -0
- package/src/components/link/link.tsx +163 -57
- package/src/components/link/link.types.ts +261 -0
- package/src/components/list/README.mdx +764 -0
- package/src/components/list/list.scss +285 -0
- package/src/components/list/list.stories.tsx +514 -27
- package/src/components/list/list.test.tsx +554 -0
- package/src/components/list/list.tsx +153 -51
- package/src/components/list/list.types.ts +255 -0
- package/src/components/nav/ACCESSIBILITY.md +649 -0
- package/src/components/nav/README.mdx +782 -0
- package/src/components/nav/nav.scss +32 -1
- package/src/components/nav/nav.stories.tsx +44 -6
- package/src/components/nav/nav.tsx +302 -51
- package/src/components/nav/nav.types.ts +308 -0
- package/src/components/tag/README.mdx +426 -0
- package/src/components/tag/tag.scss +101 -27
- package/src/components/tag/tag.stories.tsx +384 -10
- package/src/components/tag/tag.test.tsx +210 -0
- package/src/components/tag/tag.tsx +106 -9
- package/src/components/tag/tag.types.ts +107 -0
- package/src/components/ui.tsx +8 -3
- package/src/hooks/use-disabled-state.test.tsx +536 -0
- package/src/hooks/use-disabled-state.ts +246 -0
- package/src/hooks/useDisabledState.md +393 -0
- package/src/hooks.ts +6 -0
- package/src/index.scss +2 -0
- package/src/index.ts +2 -1
- package/src/sass/_globals.scss +2 -7
- package/src/styles/alert/alert.css +1 -3
- package/src/styles/alert/alert.css.map +1 -1
- package/src/styles/index.css +450 -76
- package/src/styles/index.css.map +1 -1
- package/src/styles/link/link.css +45 -28
- package/src/styles/link/link.css.map +1 -1
- package/src/styles/list/list.css +214 -0
- package/src/styles/list/list.css.map +1 -0
- package/src/styles/nav/nav.css +21 -1
- package/src/styles/nav/nav.css.map +1 -1
- package/src/styles/tag/tag.css +113 -35
- package/src/styles/tag/tag.css.map +1 -1
- package/src/styles/utilities/_disabled.scss +58 -0
- package/src/types/shared.ts +43 -6
- package/src/utils/accessibility.ts +109 -0
- package/libs/chunk-2LTJ7HHX.cjs +0 -18
- package/libs/chunk-2LTJ7HHX.cjs.map +0 -1
- package/libs/chunk-2Y7W75TT.js +0 -9
- package/libs/chunk-2Y7W75TT.js.map +0 -1
- package/libs/chunk-5S4ORA4C.cjs +0 -15
- package/libs/chunk-5S4ORA4C.cjs.map +0 -1
- package/libs/chunk-AHDJGCG5.cjs +0 -15
- package/libs/chunk-AHDJGCG5.cjs.map +0 -1
- package/libs/chunk-BHRQBJRY.js +0 -8
- package/libs/chunk-BHRQBJRY.js.map +0 -1
- package/libs/chunk-GZ4QFPRY.js +0 -9
- package/libs/chunk-GZ4QFPRY.js.map +0 -1
- package/libs/chunk-IYUN2EW3.cjs +0 -15
- package/libs/chunk-IYUN2EW3.cjs.map +0 -1
- package/libs/chunk-J32EZPYD.cjs +0 -15
- package/libs/chunk-J32EZPYD.cjs.map +0 -1
- package/libs/chunk-KUKIVRC2.js +0 -7
- package/libs/chunk-KUKIVRC2.js.map +0 -1
- package/libs/chunk-L75OQKEI.cjs.map +0 -1
- package/libs/chunk-M5RRNTVX.cjs +0 -15
- package/libs/chunk-M5RRNTVX.cjs.map +0 -1
- package/libs/chunk-OK5QEIMD.cjs +0 -17
- package/libs/chunk-OK5QEIMD.cjs.map +0 -1
- package/libs/chunk-P7TTEYCD.js +0 -7
- package/libs/chunk-P7TTEYCD.js.map +0 -1
- package/libs/chunk-QLZWHAMK.js +0 -8
- package/libs/chunk-QLZWHAMK.js.map +0 -1
- package/libs/chunk-RIVUMPOG.js +0 -8
- package/libs/chunk-RIVUMPOG.js.map +0 -1
- package/libs/chunk-S7BABR7Z.cjs +0 -13
- package/libs/chunk-S7BABR7Z.cjs.map +0 -1
- package/libs/chunk-SMYRLO3E.js +0 -8
- package/libs/chunk-SMYRLO3E.js.map +0 -1
- package/libs/chunk-TYRCEX2L.js +0 -8
- package/libs/chunk-TYRCEX2L.js.map +0 -1
- package/libs/chunk-XBA562WW.js +0 -8
- package/libs/chunk-XBA562WW.js.map +0 -1
- package/libs/chunk-XTQKWY7W.cjs +0 -32
- package/libs/chunk-XTQKWY7W.cjs.map +0 -1
- package/libs/inputs-f3a216db.d.ts +0 -45
- /package/libs/{chunk-PQ2K3BM6.cjs.map → chunk-2NRIP6RB.cjs.map} +0 -0
- /package/libs/{chunk-772NRB75.js.map → chunk-5QD3DWFI.js.map} +0 -0
- /package/libs/{chunk-3MKLDCKQ.cjs.map → chunk-6WTC4JXH.cjs.map} +0 -0
- /package/libs/{chunk-ZANSFMTD.js.map → chunk-7XPFW7CB.js.map} +0 -0
- /package/libs/{chunk-ROZI23GS.cjs.map → chunk-DKTHCQ5P.cjs.map} +0 -0
- /package/libs/{chunk-NGTJDDFO.js.map → chunk-IQ76HGVP.js.map} +0 -0
- /package/libs/{chunk-JJ43O4Y5.js.map → chunk-KK47SYZI.js.map} +0 -0
- /package/libs/{chunk-D4YLRWAO.cjs.map → chunk-QVW6W76L.cjs.map} +0 -0
- /package/libs/{chunk-LT5KZ2QW.cjs.map → chunk-US2I5GI7.cjs.map} +0 -0
- /package/libs/{chunk-B7F5FS6D.cjs.map → chunk-W2UIN7EV.cjs.map} +0 -0
- /package/libs/{chunk-P2DC76ZZ.cjs.map → chunk-W5TKWBFC.cjs.map} +0 -0
- /package/libs/{chunk-VUH3FXGJ.js.map → chunk-X3JCTEPD.js.map} +0 -0
- /package/libs/{chunk-5M57K4SW.js.map → chunk-Y2PFDELK.js.map} +0 -0
- /package/libs/{chunk-ETFLFC2S.js.map → chunk-ZFJ4U45S.js.map} +0 -0
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
import { Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
<Meta title="FP.REACT Components/Links/Readme" />
|
|
4
|
+
|
|
5
|
+
# Link Component
|
|
6
|
+
|
|
7
|
+
A semantic, accessible anchor component with enhanced security for external
|
|
8
|
+
links, customizable styling variants, and full WCAG 2.1 AA compliance. The Link
|
|
9
|
+
component wraps native `<a>` elements with automatic security attributes,
|
|
10
|
+
flexible styling, and programmatic focus management.
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
The `Link` component provides a type-safe, accessible way to create hyperlinks
|
|
15
|
+
with built-in security for external URLs, button-styled variants, and
|
|
16
|
+
performance optimizations. It automatically adds `rel="noopener noreferrer"` to
|
|
17
|
+
external links, supports ref forwarding for focus management, and offers
|
|
18
|
+
flexible styling through CSS custom properties.
|
|
19
|
+
|
|
20
|
+
**Latest Version:** v1.0.0+
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- 🔒 **Automatic Security** - External links (`target="_blank"`) get
|
|
25
|
+
`rel="noopener noreferrer"` automatically
|
|
26
|
+
- ♿ **WCAG 2.1 AA Compliant** - Accessible focus indicators, semantic HTML, and
|
|
27
|
+
screen reader support
|
|
28
|
+
- 🎨 **Flexible Styling** - Text links, button-styled links, and pill variants
|
|
29
|
+
via CSS custom properties
|
|
30
|
+
- ⚡ **Performance Optimized** - useMemo for rel computation, ref forwarding,
|
|
31
|
+
and minimal re-renders
|
|
32
|
+
- 🎯 **Ref Forwarding** - Direct DOM access for skip links, focus management,
|
|
33
|
+
and scroll positioning
|
|
34
|
+
- 🔧 **Type-Safe** - Comprehensive TypeScript types with JSDoc documentation
|
|
35
|
+
- 🧪 **100% Tested** - Complete test coverage with accessibility validation via
|
|
36
|
+
axe-core
|
|
37
|
+
- 📦 **Zero Dependencies** - Only relies on React and the UI component
|
|
38
|
+
|
|
39
|
+
## Accessibility
|
|
40
|
+
|
|
41
|
+
- ✅ Semantic `<a>` element for proper navigation and screen reader
|
|
42
|
+
announcements
|
|
43
|
+
- ✅ Focus indicators meet WCAG 2.4.7 (3:1 contrast ratio minimum)
|
|
44
|
+
- ✅ `:focus-visible` support for better UX (keyboard vs mouse differentiation)
|
|
45
|
+
- ✅ External links include automatic security attributes
|
|
46
|
+
- ✅ Supports `aria-label` for icon-only or ambiguous links
|
|
47
|
+
- ✅ Ref forwarding enables skip-link patterns and programmatic focus
|
|
48
|
+
- ✅ No keyboard traps - standard tab navigation works as expected
|
|
49
|
+
- ✅ Screen readers announce link purpose and destination
|
|
50
|
+
|
|
51
|
+
**Accessibility Rating:** ✅ A (Excellent) - WCAG 2.1 Level AA
|
|
52
|
+
|
|
53
|
+
## Props
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
type LinkProps = {
|
|
57
|
+
/** The URL that the hyperlink points to (relative or absolute) */
|
|
58
|
+
href?: string;
|
|
59
|
+
|
|
60
|
+
/** Where to display the linked URL (_self, _blank, _parent, _top) */
|
|
61
|
+
target?: string;
|
|
62
|
+
|
|
63
|
+
/** Relationship between current document and linked URL */
|
|
64
|
+
rel?: string;
|
|
65
|
+
|
|
66
|
+
/** Content to display inside the link */
|
|
67
|
+
children: React.ReactNode;
|
|
68
|
+
|
|
69
|
+
/** Inline CSS styles (can override CSS custom properties) */
|
|
70
|
+
styles?: React.CSSProperties;
|
|
71
|
+
|
|
72
|
+
/** Hints to browser to prefetch the resource (added to rel for target="_blank") */
|
|
73
|
+
prefetch?: boolean;
|
|
74
|
+
|
|
75
|
+
/** Applies button-like styling to the link */
|
|
76
|
+
btnStyle?: string;
|
|
77
|
+
|
|
78
|
+
/** Event handler called when link is clicked or activated (RECOMMENDED for analytics) */
|
|
79
|
+
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
|
80
|
+
|
|
81
|
+
/** Event handler for pointer down events (mouse, touch, pen) - does NOT fire for keyboard */
|
|
82
|
+
onPointerDown?: (event: React.PointerEvent<HTMLAnchorElement>) => void;
|
|
83
|
+
} & React.ComponentProps<typeof UI> &
|
|
84
|
+
React.ComponentPropsWithoutRef<"a">;
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Default Values
|
|
88
|
+
|
|
89
|
+
| Prop | Default | Description |
|
|
90
|
+
| ---------- | ----------- | --------------------------------- |
|
|
91
|
+
| `href` | `undefined` | Required for valid links |
|
|
92
|
+
| `target` | `undefined` | Default browser behavior (\_self) |
|
|
93
|
+
| `rel` | `undefined` | Auto-computed for external links |
|
|
94
|
+
| `prefetch` | `false` | No prefetch by default |
|
|
95
|
+
| `btnStyle` | `undefined` | Standard link styling (no button) |
|
|
96
|
+
|
|
97
|
+
## Usage Examples
|
|
98
|
+
|
|
99
|
+
### Basic Link
|
|
100
|
+
|
|
101
|
+
Simple internal link with descriptive text:
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
import { Link } from "@fpkit/acss";
|
|
105
|
+
|
|
106
|
+
function Navigation() {
|
|
107
|
+
return <Link href="/about">About Us</Link>;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### External Link with Automatic Security
|
|
112
|
+
|
|
113
|
+
External links automatically include `rel="noopener noreferrer"`:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { Link } from "@fpkit/acss";
|
|
117
|
+
|
|
118
|
+
function ExternalResource() {
|
|
119
|
+
return (
|
|
120
|
+
<Link href="https://example.com" target="_blank">
|
|
121
|
+
Visit Example.com
|
|
122
|
+
</Link>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Rendered HTML:
|
|
127
|
+
// <a href="https://example.com" target="_blank" rel="noopener noreferrer">
|
|
128
|
+
// Visit Example.com
|
|
129
|
+
// </a>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### External Link with Custom rel Attributes
|
|
133
|
+
|
|
134
|
+
Custom `rel` values are **merged** with security defaults (not replaced):
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
import { Link } from "@fpkit/acss";
|
|
138
|
+
|
|
139
|
+
function ExternalNoFollow() {
|
|
140
|
+
return (
|
|
141
|
+
<Link href="https://example.com" target="_blank" rel="nofollow author">
|
|
142
|
+
External Resource
|
|
143
|
+
</Link>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Rendered HTML includes: noopener, noreferrer, nofollow, author
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### External Link with Prefetch
|
|
151
|
+
|
|
152
|
+
Performance optimization for anticipated navigation:
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
import { Link } from "@fpkit/acss";
|
|
156
|
+
|
|
157
|
+
function NextPage() {
|
|
158
|
+
return (
|
|
159
|
+
<Link href="https://example.com/next" target="_blank" prefetch>
|
|
160
|
+
Next Page
|
|
161
|
+
</Link>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Rendered HTML:
|
|
166
|
+
// <a ... rel="noopener noreferrer prefetch">
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Button-Styled Link
|
|
170
|
+
|
|
171
|
+
Use `<b>` wrapper for button styling (maintains semantic `<a>`):
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
import { Link } from "@fpkit/acss";
|
|
175
|
+
|
|
176
|
+
function CallToAction() {
|
|
177
|
+
return (
|
|
178
|
+
<Link href="/signup">
|
|
179
|
+
<b>Sign Up Now</b>
|
|
180
|
+
</Link>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Pill-Styled Link
|
|
186
|
+
|
|
187
|
+
Use `<i>` wrapper for fully rounded pill styling:
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
import { Link } from "@fpkit/acss";
|
|
191
|
+
|
|
192
|
+
function PillButton() {
|
|
193
|
+
return (
|
|
194
|
+
<Link href="/get-started">
|
|
195
|
+
<i>Get Started</i>
|
|
196
|
+
</Link>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Button-Styled with data-btn Attribute
|
|
202
|
+
|
|
203
|
+
Alternative method using `btnStyle` prop:
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
import { Link } from "@fpkit/acss";
|
|
207
|
+
|
|
208
|
+
function ActionButton() {
|
|
209
|
+
return (
|
|
210
|
+
<Link href="/action" btnStyle="primary">
|
|
211
|
+
Take Action
|
|
212
|
+
</Link>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Custom Styling with CSS Variables
|
|
218
|
+
|
|
219
|
+
Override default styles using CSS custom properties:
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
import { Link } from "@fpkit/acss";
|
|
223
|
+
|
|
224
|
+
function CustomLink() {
|
|
225
|
+
return (
|
|
226
|
+
<Link
|
|
227
|
+
href="/products"
|
|
228
|
+
styles={{
|
|
229
|
+
"--link-color": "#d63384",
|
|
230
|
+
"--link-decoration": "underline",
|
|
231
|
+
"--link-weight": "600",
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
Featured Products
|
|
235
|
+
</Link>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Icon-Only Link with Accessible Label
|
|
241
|
+
|
|
242
|
+
Critical: Icon-only links **must** have `aria-label`:
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
import { Link } from "@fpkit/acss";
|
|
246
|
+
import { SettingsIcon } from "../icons";
|
|
247
|
+
|
|
248
|
+
function SettingsLink() {
|
|
249
|
+
return (
|
|
250
|
+
<Link href="/settings" aria-label="Open settings">
|
|
251
|
+
<SettingsIcon aria-hidden="true" />
|
|
252
|
+
</Link>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Skip Link with Ref Forwarding
|
|
258
|
+
|
|
259
|
+
Enable keyboard users to skip navigation:
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
import { Link } from "@fpkit/acss";
|
|
263
|
+
import { useRef, useEffect } from "react";
|
|
264
|
+
|
|
265
|
+
function SkipNavigation() {
|
|
266
|
+
const mainRef = useRef<HTMLAnchorElement>(null);
|
|
267
|
+
|
|
268
|
+
// Focus skip link on page load for keyboard users
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
271
|
+
if (e.key === "Tab" && !e.shiftKey) {
|
|
272
|
+
mainRef.current?.focus();
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
window.addEventListener("keydown", handleKeyDown, { once: true });
|
|
277
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
278
|
+
}, []);
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<Link
|
|
282
|
+
ref={mainRef}
|
|
283
|
+
href="#main-content"
|
|
284
|
+
styles={{ "--link-color": "#fff" }}
|
|
285
|
+
>
|
|
286
|
+
Skip to main content
|
|
287
|
+
</Link>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Email and Phone Links
|
|
293
|
+
|
|
294
|
+
Support for non-HTTP URL schemes:
|
|
295
|
+
|
|
296
|
+
```tsx
|
|
297
|
+
import { Link } from "@fpkit/acss";
|
|
298
|
+
|
|
299
|
+
function ContactLinks() {
|
|
300
|
+
return (
|
|
301
|
+
<>
|
|
302
|
+
<Link href="mailto:hello@example.com">Email Us</Link>
|
|
303
|
+
<Link href="tel:+1234567890">Call: +1 (234) 567-890</Link>
|
|
304
|
+
</>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Link with Event Tracking (onClick - Recommended)
|
|
310
|
+
|
|
311
|
+
**Recommended**: Use `onClick` for analytics and tracking - it captures ALL
|
|
312
|
+
activation methods including keyboard:
|
|
313
|
+
|
|
314
|
+
```tsx
|
|
315
|
+
import { Link } from "@fpkit/acss";
|
|
316
|
+
|
|
317
|
+
function TrackedLink() {
|
|
318
|
+
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
319
|
+
// Track analytics event - captures mouse, touch, AND keyboard activation
|
|
320
|
+
console.log("Link clicked:", e.currentTarget.href);
|
|
321
|
+
// Optional: prevent default and handle navigation manually
|
|
322
|
+
// e.preventDefault();
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<Link href="/products" onClick={handleLinkClick}>
|
|
327
|
+
Browse Products
|
|
328
|
+
</Link>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Link with Pointer-Specific Tracking (onPointerDown)
|
|
334
|
+
|
|
335
|
+
Use `onPointerDown` when you need pointer-specific data (mouse vs touch vs pen):
|
|
336
|
+
|
|
337
|
+
```tsx
|
|
338
|
+
import { Link } from "@fpkit/acss";
|
|
339
|
+
|
|
340
|
+
function PointerTrackedLink() {
|
|
341
|
+
const handlePointerDown = (e: React.PointerEvent<HTMLAnchorElement>) => {
|
|
342
|
+
// Track pointer type: mouse, touch, or pen
|
|
343
|
+
console.log("Pointer type:", e.pointerType);
|
|
344
|
+
console.log("Pointer ID:", e.pointerId);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<Link href="/products" onPointerDown={handlePointerDown}>
|
|
349
|
+
Browse Products
|
|
350
|
+
</Link>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
⚠️ **Accessibility Note**: `onPointerDown` does NOT fire for keyboard activation
|
|
356
|
+
(Enter key). If you need to track keyboard users, use `onClick` instead.
|
|
357
|
+
|
|
358
|
+
### Link with Both Event Handlers
|
|
359
|
+
|
|
360
|
+
Use both handlers together for comprehensive tracking:
|
|
361
|
+
|
|
362
|
+
```tsx
|
|
363
|
+
import { Link } from "@fpkit/acss";
|
|
364
|
+
|
|
365
|
+
function ComprehensiveTracking() {
|
|
366
|
+
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
367
|
+
// Captures ALL activations (mouse, touch, keyboard)
|
|
368
|
+
console.log("Link activated:", e.currentTarget.href);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const handlePointerDown = (e: React.PointerEvent<HTMLAnchorElement>) => {
|
|
372
|
+
// Captures pointer-specific data
|
|
373
|
+
console.log("Pointer type:", e.pointerType);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<Link
|
|
378
|
+
href="/products"
|
|
379
|
+
onClick={handleClick}
|
|
380
|
+
onPointerDown={handlePointerDown}
|
|
381
|
+
>
|
|
382
|
+
Browse Products
|
|
383
|
+
</Link>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Anchor Link for Same-Page Navigation
|
|
389
|
+
|
|
390
|
+
Jump to sections within the same page:
|
|
391
|
+
|
|
392
|
+
```tsx
|
|
393
|
+
import { Link } from "@fpkit/acss";
|
|
394
|
+
|
|
395
|
+
function TableOfContents() {
|
|
396
|
+
return (
|
|
397
|
+
<nav>
|
|
398
|
+
<Link href="#introduction">Introduction</Link>
|
|
399
|
+
<Link href="#features">Features</Link>
|
|
400
|
+
<Link href="#examples">Examples</Link>
|
|
401
|
+
</nav>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Link with Additional Attributes
|
|
407
|
+
|
|
408
|
+
Spread all native `<a>` attributes:
|
|
409
|
+
|
|
410
|
+
```tsx
|
|
411
|
+
import { Link } from "@fpkit/acss";
|
|
412
|
+
|
|
413
|
+
function DetailedLink() {
|
|
414
|
+
return (
|
|
415
|
+
<Link
|
|
416
|
+
href="/download"
|
|
417
|
+
download
|
|
418
|
+
title="Download the user guide PDF"
|
|
419
|
+
aria-describedby="download-description"
|
|
420
|
+
>
|
|
421
|
+
Download Guide
|
|
422
|
+
</Link>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## Styling
|
|
428
|
+
|
|
429
|
+
The component uses SCSS with CSS custom properties for theming.
|
|
430
|
+
|
|
431
|
+
### CSS Custom Properties
|
|
432
|
+
|
|
433
|
+
```css
|
|
434
|
+
a[href] {
|
|
435
|
+
/* Color & Typography */
|
|
436
|
+
--link-color: #085ab7;
|
|
437
|
+
--link-weight: 400;
|
|
438
|
+
--link-fs: 1rem;
|
|
439
|
+
|
|
440
|
+
/* Text Decoration */
|
|
441
|
+
--link-decoration: none;
|
|
442
|
+
--link-decoration-offset: 0.09375rem; /* 1.5px */
|
|
443
|
+
--link-decoration-thickness: 0.1875rem; /* 3px */
|
|
444
|
+
--link-skip-ink: auto;
|
|
445
|
+
|
|
446
|
+
/* Background & Border */
|
|
447
|
+
--link-bg: transparent;
|
|
448
|
+
--link-radius: 0.25rem;
|
|
449
|
+
|
|
450
|
+
/* Focus Indicator (WCAG 2.4.7) */
|
|
451
|
+
--link-focus-color: currentColor;
|
|
452
|
+
--link-focus-width: 0.125rem; /* 2px */
|
|
453
|
+
--link-focus-offset: 0.125rem; /* 2px */
|
|
454
|
+
--link-focus-style: solid;
|
|
455
|
+
|
|
456
|
+
/* Button Variant (when using <b> or <i>) */
|
|
457
|
+
--link-button-color: var(--link-color);
|
|
458
|
+
--link-border-width: 0.125rem; /* 2px */
|
|
459
|
+
--link-border-color: currentColor;
|
|
460
|
+
--link-border-style: solid;
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### Theming Example
|
|
465
|
+
|
|
466
|
+
Create a custom theme by overriding CSS variables:
|
|
467
|
+
|
|
468
|
+
```css
|
|
469
|
+
/* Dark theme example */
|
|
470
|
+
.dark-theme a[href] {
|
|
471
|
+
--link-color: #66b3ff;
|
|
472
|
+
--link-focus-color: #fff;
|
|
473
|
+
--link-decoration: underline;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* Brand color override */
|
|
477
|
+
.brand-links a[href] {
|
|
478
|
+
--link-color: #ff6b6b;
|
|
479
|
+
--link-weight: 600;
|
|
480
|
+
--link-decoration-thickness: 0.25rem;
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### Button Variant Styling
|
|
485
|
+
|
|
486
|
+
Button-styled links (via `<b>`, `<i>`, or `data-btn`) automatically apply:
|
|
487
|
+
|
|
488
|
+
- Inline-flex display with centered content
|
|
489
|
+
- Padding based on font size
|
|
490
|
+
- Border outline with hover effects
|
|
491
|
+
- Scale transition on interaction
|
|
492
|
+
|
|
493
|
+
```scss
|
|
494
|
+
// Applied when link contains <b>, <i>, or has data-btn attribute
|
|
495
|
+
a[href]:has(> b),
|
|
496
|
+
a[href][data-btn],
|
|
497
|
+
a[href]:has(> i) {
|
|
498
|
+
display: inline-flex;
|
|
499
|
+
align-items: center;
|
|
500
|
+
padding-inline: var(--link-fs);
|
|
501
|
+
padding-block: calc(var(--link-fs) - 0.4rem);
|
|
502
|
+
outline: var(--link-border-width) var(--link-border-color) var(--link-border-style);
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
## Security Considerations
|
|
507
|
+
|
|
508
|
+
### Automatic Security for External Links
|
|
509
|
+
|
|
510
|
+
The Link component **automatically protects** against common vulnerabilities
|
|
511
|
+
when opening links in new tabs:
|
|
512
|
+
|
|
513
|
+
#### window.opener Exploit Prevention
|
|
514
|
+
|
|
515
|
+
When `target="_blank"` is used without `rel="noopener"`, the opened page can
|
|
516
|
+
access the opener window via `window.opener` and potentially navigate it to a
|
|
517
|
+
malicious URL.
|
|
518
|
+
|
|
519
|
+
```tsx
|
|
520
|
+
// ✅ SAFE: Automatic protection
|
|
521
|
+
<Link href="https://example.com" target="_blank">
|
|
522
|
+
External Link
|
|
523
|
+
</Link>
|
|
524
|
+
// Renders: <a ... rel="noopener noreferrer">
|
|
525
|
+
|
|
526
|
+
// ❌ UNSAFE: Raw anchor without protection
|
|
527
|
+
<a href="https://example.com" target="_blank">
|
|
528
|
+
External Link
|
|
529
|
+
</a>
|
|
530
|
+
// Vulnerable to window.opener attacks!
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
#### Referrer Header Privacy
|
|
534
|
+
|
|
535
|
+
The `noreferrer` attribute prevents the browser from sending the `Referer` HTTP
|
|
536
|
+
header, protecting user privacy by not disclosing the source page URL.
|
|
537
|
+
|
|
538
|
+
### Custom rel Merging
|
|
539
|
+
|
|
540
|
+
User-provided `rel` values are **merged** with security defaults, not replaced:
|
|
541
|
+
|
|
542
|
+
```tsx
|
|
543
|
+
// User wants: nofollow author
|
|
544
|
+
<Link href="https://example.com" target="_blank" rel="nofollow author">
|
|
545
|
+
External
|
|
546
|
+
</Link>
|
|
547
|
+
|
|
548
|
+
// Component renders: noopener noreferrer nofollow author
|
|
549
|
+
// Security + custom values = secure AND functional
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
## Best Practices
|
|
553
|
+
|
|
554
|
+
### Accessibility
|
|
555
|
+
|
|
556
|
+
1. **Use Descriptive Link Text**
|
|
557
|
+
|
|
558
|
+
```tsx
|
|
559
|
+
// ✅ GOOD: Descriptive text makes sense out of context
|
|
560
|
+
<Link href="/docs/installation">Read installation guide</Link>
|
|
561
|
+
|
|
562
|
+
// ❌ BAD: Generic text is meaningless for screen readers
|
|
563
|
+
<Link href="/docs/installation">Click here</Link>
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
2. **Provide aria-label for Icon-Only Links**
|
|
567
|
+
|
|
568
|
+
```tsx
|
|
569
|
+
// ✅ GOOD: Screen readers can announce the link purpose
|
|
570
|
+
<Link href="/settings" aria-label="Open settings">
|
|
571
|
+
<SettingsIcon aria-hidden="true" />
|
|
572
|
+
</Link>
|
|
573
|
+
|
|
574
|
+
// ❌ BAD: No accessible name for screen readers
|
|
575
|
+
<Link href="/settings">
|
|
576
|
+
<SettingsIcon />
|
|
577
|
+
</Link>
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
3. **Don't Rely Only on Color**
|
|
581
|
+
|
|
582
|
+
```tsx
|
|
583
|
+
// ✅ GOOD: Underline + color provides multiple indicators
|
|
584
|
+
<Link href="/important" styles={{ '--link-decoration': 'underline' }}>
|
|
585
|
+
Important Notice
|
|
586
|
+
</Link>
|
|
587
|
+
|
|
588
|
+
// ⚠️ OKAY: Color alone may fail WCAG 1.4.1 (Use of Color)
|
|
589
|
+
<Link href="/important" styles={{ '--link-color': 'red' }}>
|
|
590
|
+
Important Notice
|
|
591
|
+
</Link>
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
4. **Ensure Sufficient Focus Indicators**
|
|
595
|
+
|
|
596
|
+
The component meets WCAG 2.4.7 by default, but custom styles should maintain
|
|
597
|
+
contrast:
|
|
598
|
+
|
|
599
|
+
```tsx
|
|
600
|
+
// ✅ GOOD: Maintains visible focus indicator
|
|
601
|
+
<Link
|
|
602
|
+
href="/test"
|
|
603
|
+
styles={{
|
|
604
|
+
'--link-focus-width': '0.1875rem', // 3px
|
|
605
|
+
'--link-focus-color': '#000',
|
|
606
|
+
}}
|
|
607
|
+
>
|
|
608
|
+
Test
|
|
609
|
+
</Link>
|
|
610
|
+
|
|
611
|
+
// ❌ BAD: Removes focus indicator (WCAG violation)
|
|
612
|
+
<Link href="/test" styles={{ outline: 'none' }}>
|
|
613
|
+
Test
|
|
614
|
+
</Link>
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### Event Handlers
|
|
618
|
+
|
|
619
|
+
1. **Use onClick for Analytics and Tracking**
|
|
620
|
+
|
|
621
|
+
```tsx
|
|
622
|
+
// ✅ GOOD: onClick tracks all activation methods (mouse, touch, keyboard)
|
|
623
|
+
<Link
|
|
624
|
+
href="/products"
|
|
625
|
+
onClick={(e) => trackEvent('link_click', { href: '/products' })}
|
|
626
|
+
>
|
|
627
|
+
Products
|
|
628
|
+
</Link>
|
|
629
|
+
|
|
630
|
+
// ❌ BAD: onPointerDown misses keyboard users
|
|
631
|
+
<Link
|
|
632
|
+
href="/products"
|
|
633
|
+
onPointerDown={(e) => trackEvent('link_click', { href: '/products' })}
|
|
634
|
+
>
|
|
635
|
+
Products
|
|
636
|
+
</Link>
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
2. **Use onPointerDown for Pointer-Specific Interactions**
|
|
640
|
+
|
|
641
|
+
```tsx
|
|
642
|
+
// ✅ GOOD: onPointerDown for distinguishing input types
|
|
643
|
+
<Link
|
|
644
|
+
href="/drawing"
|
|
645
|
+
onPointerDown={(e) => {
|
|
646
|
+
if (e.pointerType === "pen") {
|
|
647
|
+
enablePenMode();
|
|
648
|
+
}
|
|
649
|
+
}}
|
|
650
|
+
>
|
|
651
|
+
Drawing Tool
|
|
652
|
+
</Link>
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
3. **Combine Both Handlers When Needed**
|
|
656
|
+
|
|
657
|
+
```tsx
|
|
658
|
+
// ✅ GOOD: Use both for comprehensive tracking
|
|
659
|
+
<Link
|
|
660
|
+
href="/products"
|
|
661
|
+
onClick={(e) => trackAllUsers(e)}
|
|
662
|
+
onPointerDown={(e) => trackPointerType(e.pointerType)}
|
|
663
|
+
>
|
|
664
|
+
Products
|
|
665
|
+
</Link>
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
4. **Memoize Event Handlers**
|
|
669
|
+
|
|
670
|
+
```tsx
|
|
671
|
+
import { useCallback } from "react";
|
|
672
|
+
|
|
673
|
+
// ✅ GOOD: Memoized callback prevents re-renders
|
|
674
|
+
const handleClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
675
|
+
trackEvent("link_click", { href: e.currentTarget.href });
|
|
676
|
+
}, []);
|
|
677
|
+
|
|
678
|
+
return (
|
|
679
|
+
<Link href="/test" onClick={handleClick}>
|
|
680
|
+
Test
|
|
681
|
+
</Link>
|
|
682
|
+
);
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
### Performance
|
|
686
|
+
|
|
687
|
+
1. **Use prefetch Judiciously**
|
|
688
|
+
|
|
689
|
+
```tsx
|
|
690
|
+
// ✅ GOOD: Prefetch likely next navigation
|
|
691
|
+
<Link href="/checkout" target="_blank" prefetch>
|
|
692
|
+
Proceed to Checkout
|
|
693
|
+
</Link>;
|
|
694
|
+
|
|
695
|
+
// ❌ BAD: Prefetching too many resources wastes bandwidth
|
|
696
|
+
{
|
|
697
|
+
links.map((link) => (
|
|
698
|
+
<Link key={link.id} href={link.url} prefetch>
|
|
699
|
+
{link.title}
|
|
700
|
+
</Link>
|
|
701
|
+
));
|
|
702
|
+
}
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
### Styling
|
|
706
|
+
|
|
707
|
+
1. **Use rem Units for Accessibility**
|
|
708
|
+
|
|
709
|
+
```tsx
|
|
710
|
+
// ✅ GOOD: rem units scale with user font size preferences
|
|
711
|
+
<Link styles={{ '--link-fs': '1.125rem' }}>Large Link</Link>
|
|
712
|
+
|
|
713
|
+
// ❌ BAD: px units ignore user preferences
|
|
714
|
+
<Link styles={{ '--link-fs': '18px' }}>Large Link</Link>
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
2. **Maintain Semantic HTML**
|
|
718
|
+
|
|
719
|
+
```tsx
|
|
720
|
+
// ✅ GOOD: Button-styled link maintains <a> semantics
|
|
721
|
+
<Link href="/signup"><b>Sign Up</b></Link>
|
|
722
|
+
|
|
723
|
+
// ❌ BAD: Don't use buttons for navigation
|
|
724
|
+
<button onClick={() => navigate('/signup')}>Sign Up</button>
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
### Security
|
|
728
|
+
|
|
729
|
+
1. **Trust Automatic Security**
|
|
730
|
+
|
|
731
|
+
```tsx
|
|
732
|
+
// ✅ GOOD: Let component handle security
|
|
733
|
+
<Link href="https://example.com" target="_blank">External</Link>
|
|
734
|
+
|
|
735
|
+
// ⚠️ UNNECESSARY: Component already adds these
|
|
736
|
+
<Link href="https://example.com" target="_blank" rel="noopener noreferrer">
|
|
737
|
+
External
|
|
738
|
+
</Link>
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
2. **Validate User-Provided URLs**
|
|
742
|
+
|
|
743
|
+
```tsx
|
|
744
|
+
import { Link } from "@fpkit/acss";
|
|
745
|
+
|
|
746
|
+
function UserSubmittedLink({ url }: { url: string }) {
|
|
747
|
+
// ✅ GOOD: Validate/sanitize user input
|
|
748
|
+
const isValidUrl = url.startsWith("http://") || url.startsWith("https://");
|
|
749
|
+
|
|
750
|
+
if (!isValidUrl) {
|
|
751
|
+
return <span>Invalid URL</span>;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return (
|
|
755
|
+
<Link href={url} target="_blank">
|
|
756
|
+
Visit
|
|
757
|
+
</Link>
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
## Technical Details
|
|
763
|
+
|
|
764
|
+
### Component Architecture
|
|
765
|
+
|
|
766
|
+
- **Functional Component** with `React.forwardRef` for ref support
|
|
767
|
+
- **Performance Optimized** with `React.useMemo` for rel computation
|
|
768
|
+
- **Type-Safe** with extracted TypeScript definitions in `link.types.ts`
|
|
769
|
+
- **Accessible** by leveraging semantic HTML5 `<a>` element
|
|
770
|
+
- **Composable** via UI component for polymorphic flexibility
|
|
771
|
+
|
|
772
|
+
### Browser Support
|
|
773
|
+
|
|
774
|
+
Works in all modern browsers supporting:
|
|
775
|
+
|
|
776
|
+
- React 18+
|
|
777
|
+
- CSS Custom Properties (IE 11+ with fallbacks)
|
|
778
|
+
- `:focus-visible` pseudo-class (progressive enhancement)
|
|
779
|
+
- `rel="noopener noreferrer"` (all modern browsers)
|
|
780
|
+
|
|
781
|
+
For older browsers, consider polyfills or fallback styling.
|
|
782
|
+
|
|
783
|
+
### Ref Type
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
const linkRef = useRef<HTMLAnchorElement>(null);
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
The forwarded ref is typed as `HTMLAnchorElement`, providing full DOM API
|
|
790
|
+
access:
|
|
791
|
+
|
|
792
|
+
- `linkRef.current?.focus()` - Programmatic focus
|
|
793
|
+
- `linkRef.current?.blur()` - Remove focus
|
|
794
|
+
- `linkRef.current?.click()` - Trigger click
|
|
795
|
+
- `linkRef.current?.href` - Read/write href
|
|
796
|
+
- `linkRef.current?.scrollIntoView()` - Scroll to link
|
|
797
|
+
|
|
798
|
+
## Testing
|
|
799
|
+
|
|
800
|
+
### Automated Testing
|
|
801
|
+
|
|
802
|
+
The component includes comprehensive tests covering:
|
|
803
|
+
|
|
804
|
+
```bash
|
|
805
|
+
npm test -- link.test.tsx
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
Tests include:
|
|
809
|
+
|
|
810
|
+
- ✅ Basic rendering and prop handling
|
|
811
|
+
- ✅ Security (rel attribute merging)
|
|
812
|
+
- ✅ External link protection
|
|
813
|
+
- ✅ Prefetch behavior
|
|
814
|
+
- ✅ Button styling variants
|
|
815
|
+
- ✅ Event handlers
|
|
816
|
+
- ✅ Ref forwarding
|
|
817
|
+
- ✅ Accessibility with axe-core
|
|
818
|
+
- ✅ Keyboard navigation
|
|
819
|
+
- ✅ URL scheme support (mailto, tel, etc.)
|
|
820
|
+
- ✅ Edge cases and performance
|
|
821
|
+
|
|
822
|
+
### Manual Testing Checklist
|
|
823
|
+
|
|
824
|
+
#### Keyboard Navigation
|
|
825
|
+
|
|
826
|
+
- [ ] Tab to link - receives visible focus indicator
|
|
827
|
+
- [ ] Focus indicator has 3:1 contrast minimum
|
|
828
|
+
- [ ] Enter key activates link (default browser behavior)
|
|
829
|
+
- [ ] No keyboard traps
|
|
830
|
+
|
|
831
|
+
#### Screen Reader Testing (NVDA/VoiceOver/JAWS)
|
|
832
|
+
|
|
833
|
+
- [ ] Announces as "link" role
|
|
834
|
+
- [ ] Announces link text or aria-label
|
|
835
|
+
- [ ] External links announce "opens in new window" (browser default)
|
|
836
|
+
- [ ] Icon-only links have accessible names
|
|
837
|
+
|
|
838
|
+
#### Visual Testing
|
|
839
|
+
|
|
840
|
+
- [ ] Focus indicator visible with sufficient contrast
|
|
841
|
+
- [ ] Works at 200% browser zoom
|
|
842
|
+
- [ ] Text reflows at 320px viewport width
|
|
843
|
+
- [ ] Hover state is visually distinct
|
|
844
|
+
- [ ] Button-styled links look clickable
|
|
845
|
+
|
|
846
|
+
#### Functional Testing
|
|
847
|
+
|
|
848
|
+
- [ ] Internal links navigate correctly
|
|
849
|
+
- [ ] External links open in new tab (when target="\_blank")
|
|
850
|
+
- [ ] mailto: and tel: links trigger correct apps
|
|
851
|
+
- [ ] Anchor links scroll to target element
|
|
852
|
+
- [ ] Event handlers fire as expected
|
|
853
|
+
|
|
854
|
+
## Related Components
|
|
855
|
+
|
|
856
|
+
- **Button** - For actions that don't navigate (use button, not link)
|
|
857
|
+
- **Breadcrumbs** - For navigation trails using links
|
|
858
|
+
- **Navigation** - For site navigation menus
|
|
859
|
+
|
|
860
|
+
## Resources
|
|
861
|
+
|
|
862
|
+
- [MDN: The Anchor Element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
|
|
863
|
+
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
|
|
864
|
+
- [WebAIM: Links and Hypertext](https://webaim.org/techniques/hypertext/)
|
|
865
|
+
- [rel=noopener Security](https://mathiasbynens.github.io/rel-noopener/)
|
|
866
|
+
- [ARIA Authoring Practices: Link](https://www.w3.org/WAI/ARIA/apg/patterns/link/)
|
|
867
|
+
|
|
868
|
+
## Migration Guide
|
|
869
|
+
|
|
870
|
+
### From Raw Anchor Elements
|
|
871
|
+
|
|
872
|
+
```tsx
|
|
873
|
+
// Before: Raw <a> tag
|
|
874
|
+
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
|
|
875
|
+
External Link
|
|
876
|
+
</a>
|
|
877
|
+
|
|
878
|
+
// After: Link component (automatic security)
|
|
879
|
+
<Link href="https://example.com" target="_blank">
|
|
880
|
+
External Link
|
|
881
|
+
</Link>
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
### From Previous Link Version (if applicable)
|
|
885
|
+
|
|
886
|
+
If upgrading from an older Link component that overwrote `rel` values:
|
|
887
|
+
|
|
888
|
+
```tsx
|
|
889
|
+
// Before: Custom rel values were lost
|
|
890
|
+
<Link href="https://example.com" target="_blank" rel="nofollow">
|
|
891
|
+
External
|
|
892
|
+
</Link>
|
|
893
|
+
// Old behavior: rel="noopener noreferrer" (nofollow was lost!)
|
|
894
|
+
|
|
895
|
+
// After: Custom rel values are merged
|
|
896
|
+
<Link href="https://example.com" target="_blank" rel="nofollow">
|
|
897
|
+
External
|
|
898
|
+
</Link>
|
|
899
|
+
// New behavior: rel="noopener noreferrer nofollow" (merged!)
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
## Changelog
|
|
903
|
+
|
|
904
|
+
### v1.0.0 (Latest)
|
|
905
|
+
|
|
906
|
+
- ✨ Initial release with comprehensive features
|
|
907
|
+
- ✨ Automatic security for external links (rel merging)
|
|
908
|
+
- ✨ React.forwardRef for ref forwarding and focus management
|
|
909
|
+
- ✨ useMemo optimization for rel computation
|
|
910
|
+
- ✨ Button and pill styling variants via wrappers
|
|
911
|
+
- ✨ WCAG 2.1 AA compliant focus indicators
|
|
912
|
+
- ✨ :focus-visible support for better UX
|
|
913
|
+
- ✨ Comprehensive TypeScript types in separate file
|
|
914
|
+
- ✨ 100+ test scenarios with axe-core accessibility validation
|
|
915
|
+
- ✨ Complete Storybook documentation with interactive examples
|
|
916
|
+
- 📝 Extensive JSDoc documentation
|
|
917
|
+
- ♿ Accessibility rating: A (Excellent)
|
|
918
|
+
|
|
919
|
+
---
|
|
920
|
+
|
|
921
|
+
**Need Help?** Check the
|
|
922
|
+
[Storybook examples](./?path=/docs/fp-react-components-links--docs) or review
|
|
923
|
+
the [component tests](./link.test.tsx) for usage patterns.
|