ab-nextjs-components 0.1.1 → 0.1.3

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 (109) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +1 -1
  3. package/README.md +91 -15
  4. package/ab-avatar/index.tsx +90 -0
  5. package/ab-avatar/styles.module.css +62 -0
  6. package/ab-balloon/index.tsx +55 -0
  7. package/ab-balloon/styles.module.css +12 -0
  8. package/ab-brand/index.tsx +88 -0
  9. package/ab-brand/styles.module.css +54 -0
  10. package/ab-button/index.tsx +164 -0
  11. package/ab-button/styles.module.css +63 -0
  12. package/ab-collapsible/index.tsx +188 -0
  13. package/ab-collapsible/styles.module.css +98 -0
  14. package/ab-color/index.tsx +84 -0
  15. package/ab-color/styles.module.css +51 -0
  16. package/ab-demo-box/index.tsx +38 -0
  17. package/ab-demo-box/styles.module.css +13 -0
  18. package/ab-demo-code/index.tsx +62 -0
  19. package/ab-demo-code/styles.module.css +41 -0
  20. package/ab-icon/index.tsx +63 -0
  21. package/ab-icon/styles.module.css +27 -0
  22. package/ab-icon-button/index.tsx +107 -0
  23. package/ab-icon-button/styles.module.css +64 -0
  24. package/ab-image/index.tsx +83 -0
  25. package/ab-image/styles.module.css +27 -0
  26. package/ab-info/index.tsx +89 -0
  27. package/ab-info/styles.module.css +87 -0
  28. package/ab-input/index.tsx +277 -0
  29. package/ab-input/styles.module.css +101 -0
  30. package/ab-key-feature/index.tsx +60 -0
  31. package/ab-key-feature/styles.module.css +47 -0
  32. package/ab-like-button/index.tsx +116 -0
  33. package/ab-like-button/styles.module.css +60 -0
  34. package/ab-logo/index.tsx +94 -0
  35. package/ab-logo/styles.module.css +30 -0
  36. package/ab-main-headline/index.tsx +57 -0
  37. package/ab-main-headline/styles.module.css +24 -0
  38. package/ab-menu/index.tsx +119 -0
  39. package/ab-menu/styles.module.css +99 -0
  40. package/ab-name/index.tsx +49 -0
  41. package/ab-name/styles.module.css +14 -0
  42. package/ab-navbar/index.tsx +68 -0
  43. package/ab-navbar/styles.module.css +35 -0
  44. package/ab-page-paginator/index.tsx +130 -0
  45. package/ab-page-paginator/styles.module.css +47 -0
  46. package/ab-page-switcher/index.tsx +121 -0
  47. package/ab-page-switcher/styles.module.css +37 -0
  48. package/ab-preview/index.tsx +78 -0
  49. package/ab-preview/styles.module.css +26 -0
  50. package/ab-price/index.tsx +109 -0
  51. package/ab-price/styles.module.css +36 -0
  52. package/ab-price-tag/index.tsx +70 -0
  53. package/ab-price-tag/styles.module.css +81 -0
  54. package/ab-product/index.tsx +128 -0
  55. package/ab-product/styles.module.css +44 -0
  56. package/ab-product-item/index.tsx +78 -0
  57. package/ab-product-item/styles.module.css +21 -0
  58. package/ab-progress-chips/index.tsx +105 -0
  59. package/ab-progress-chips/styles.module.css +43 -0
  60. package/ab-search-result/index.tsx +79 -0
  61. package/ab-search-result/styles.module.css +48 -0
  62. package/ab-searchbar/index.tsx +150 -0
  63. package/ab-searchbar/styles.module.css +77 -0
  64. package/ab-sidebar/index.tsx +72 -0
  65. package/ab-sidebar/styles.module.css +41 -0
  66. package/ab-switch-back/index.tsx +72 -0
  67. package/ab-switch-back/styles.module.css +45 -0
  68. package/ab-tab/index.tsx +101 -0
  69. package/ab-tab/styles.module.css +65 -0
  70. package/ab-tabs/index.tsx +45 -0
  71. package/ab-tabs/styles.module.css +17 -0
  72. package/ab-test-hello/index.tsx +37 -0
  73. package/ab-test-hello/styles.module.css +25 -0
  74. package/ab-text/index.tsx +36 -0
  75. package/ab-text/styles.module.css +3 -0
  76. package/index.ts +117 -0
  77. package/package.json +152 -7
  78. package/server/ab-avatar/index.tsx +85 -0
  79. package/server/ab-avatar/styles.module.css +62 -0
  80. package/server/ab-badge/index.tsx +66 -0
  81. package/server/ab-badge/styles.module.css +39 -0
  82. package/server/ab-brand/index.tsx +87 -0
  83. package/server/ab-brand/styles.module.css +54 -0
  84. package/server/ab-button/index.tsx +144 -0
  85. package/server/ab-button/styles.module.css +63 -0
  86. package/server/ab-icon/index.tsx +61 -0
  87. package/server/ab-icon/styles.module.css +27 -0
  88. package/server/ab-key-feature/index.tsx +59 -0
  89. package/server/ab-key-feature/styles.module.css +47 -0
  90. package/server/ab-logo/index.tsx +93 -0
  91. package/server/ab-logo/styles.module.css +30 -0
  92. package/server/ab-name/index.tsx +48 -0
  93. package/server/ab-name/styles.module.css +14 -0
  94. package/server/ab-nav-link/index.tsx +60 -0
  95. package/server/ab-nav-link/styles.module.css +32 -0
  96. package/server/ab-navbar/index.tsx +64 -0
  97. package/server/ab-navbar/styles.module.css +35 -0
  98. package/server/ab-polygon/index.tsx +45 -0
  99. package/server/ab-polygon/styles.module.css +11 -0
  100. package/server/ab-sidebar/index.tsx +68 -0
  101. package/server/ab-sidebar/styles.module.css +41 -0
  102. package/server/ab-switch-back/index.tsx +71 -0
  103. package/server/ab-switch-back/styles.module.css +45 -0
  104. package/server/ab-test-hello/index.tsx +35 -0
  105. package/server/ab-test-hello/styles.module.css +22 -0
  106. package/server/ab-text/index.tsx +33 -0
  107. package/server/ab-text/styles.module.css +3 -0
  108. package/server/index.ts +27 -0
  109. package/types/index.d.ts +54 -0
@@ -0,0 +1,277 @@
1
+ /*
2
+ * @license MIT
3
+ * ~~~~~~~~~~~~
4
+ * ab-nextjs-components
5
+ * ~~~~~~~~~~~~
6
+ * Copyright (c) 2026 Abraham Ukachi. The abElements Project.
7
+ *
8
+ * @project: ab-nextjs-components
9
+ * @name: AbInput - Client (port of lyd-input)
10
+ * @file: ab-input/index.tsx
11
+ */
12
+
13
+ 'use client';
14
+
15
+ import type {
16
+ ChangeEvent,
17
+ CSSProperties,
18
+ FocusEvent,
19
+ KeyboardEvent,
20
+ MouseEvent,
21
+ } from 'react';
22
+ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
23
+ import clsx from 'clsx';
24
+ import { useAbToggle } from 'ab-nextjs-hooks/helpers/useAbToggle';
25
+ import AbIconButton from '../ab-icon-button';
26
+ import AbBalloon from '../ab-balloon';
27
+ import styles from './styles.module.css';
28
+
29
+
30
+ export interface AbInputProps {
31
+ id: string;
32
+ type?: string;
33
+ inputMode?: 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search' | 'none';
34
+ wrapperClassName?: string;
35
+ className?: string;
36
+ style?: CSSProperties;
37
+ hidden?: boolean;
38
+ label: string;
39
+ isLabelRaised?: boolean;
40
+ required?: boolean;
41
+ capitalized?: boolean;
42
+ autoresize?: boolean;
43
+ autoresizeMultiplier?: number;
44
+ name: string;
45
+ minLength?: number;
46
+ maxLength?: number;
47
+ min?: number;
48
+ max?: number;
49
+ pattern?: RegExp;
50
+ placeholder?: string;
51
+ hasError?: boolean;
52
+ message?: string;
53
+ value?: string;
54
+ spellCheck?: boolean;
55
+ autoComplete?: string;
56
+ isReadOnly?: boolean;
57
+ isDisabled?: boolean;
58
+ hasIcon?: boolean;
59
+ icon?: string;
60
+ hasActionButton?: boolean;
61
+ actionIcon?: string;
62
+ actionLink?: string;
63
+ isLoading?: boolean;
64
+ indicatorHidden?: boolean;
65
+ togglePasswordEnabled?: boolean;
66
+ isClearable?: boolean;
67
+ onChange?: (value?: string, event?: ChangeEvent<HTMLInputElement>) => void;
68
+ onEnter?: (value?: string, event?: KeyboardEvent<HTMLInputElement>) => void;
69
+ onKeyDown?: (value?: string, event?: KeyboardEvent<HTMLInputElement>) => void;
70
+ onKeyUp?: (value?: string, event?: KeyboardEvent<HTMLInputElement>) => void;
71
+ onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
72
+ onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
73
+ onAction?: (event: MouseEvent<HTMLButtonElement>) => void;
74
+ onClear?: (event: MouseEvent<HTMLButtonElement>) => void;
75
+ onPasswordToggle?: (isPasswordVisible: boolean, event: MouseEvent<HTMLButtonElement>) => void;
76
+ showPasswordLabel?: string;
77
+ hidePasswordLabel?: string;
78
+ clearLabel?: string;
79
+ }
80
+
81
+ function computeType(type: string | undefined, isPasswordVisible: boolean): string {
82
+ if (type === 'password') return isPasswordVisible ? 'text' : 'password';
83
+ return type ?? 'text';
84
+ }
85
+
86
+ function computeAutoresizeWidth(value: string | undefined, multiplier: number): string {
87
+ const len = Math.max((value ?? '').length, 1);
88
+ return `${len * multiplier}px`;
89
+ }
90
+
91
+ const AbInput = forwardRef<HTMLInputElement, AbInputProps>(function AbInput(props, ref) {
92
+ const [isPasswordVisible, toggleIsPasswordVisible] = useAbToggle(false);
93
+ const [hasValue, toggleHasValue] = useAbToggle(false);
94
+
95
+ const showPasswordLabel = props.showPasswordLabel ?? 'Show password';
96
+ const hidePasswordLabel = props.hidePasswordLabel ?? 'Hide password';
97
+ const clearLabel = props.clearLabel ?? 'Clear';
98
+
99
+ const inputRef = useRef<HTMLInputElement>(null);
100
+ useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
101
+
102
+ useEffect(() => {
103
+ toggleHasValue(Boolean(props.value && props.value.length > 0));
104
+ }, [props.value, toggleHasValue]);
105
+
106
+ const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
107
+ const next = event.target.value;
108
+ toggleHasValue(next.length > 0);
109
+ props.onChange?.(next, event);
110
+ };
111
+
112
+ const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
113
+ const current = (event.target as HTMLInputElement).value;
114
+ props.onKeyDown?.(current, event);
115
+ if (event.key === 'Enter') props.onEnter?.(current, event);
116
+ };
117
+
118
+ const handleKeyUp = (event: KeyboardEvent<HTMLInputElement>) => {
119
+ props.onKeyUp?.((event.target as HTMLInputElement).value, event);
120
+ };
121
+
122
+ const handleClear = (event: MouseEvent<HTMLButtonElement>) => {
123
+ if (inputRef.current) {
124
+ inputRef.current.value = '';
125
+ toggleHasValue(false);
126
+ props.onChange?.('', undefined);
127
+ }
128
+ props.onClear?.(event);
129
+ };
130
+
131
+ const handlePasswordToggle = (event: MouseEvent<HTMLButtonElement>) => {
132
+ const next = !isPasswordVisible;
133
+ toggleIsPasswordVisible(next);
134
+ props.onPasswordToggle?.(Boolean(next), event);
135
+ };
136
+
137
+ return (
138
+ <div
139
+ className={clsx('AbInput', 'input-wrapper', styles.abInput, props.wrapperClassName)}
140
+ hidden={props.hidden}
141
+ data-has-error={props.hasError ? 'true' : 'false'}
142
+ data-has-msg={Boolean(props.message).toString()}
143
+ style={props.style ?? {}}
144
+ >
145
+ <label
146
+ className={clsx('Label', styles.abInputLabel)}
147
+ htmlFor={props.id}
148
+ data-raised={(props.isLabelRaised || hasValue) ? 'true' : 'false'}
149
+ >
150
+ {props.label}
151
+ </label>
152
+
153
+ <div className={styles.row}>
154
+ {props.hasIcon && props.icon ? (
155
+ <AbIconButton
156
+ icon={props.icon}
157
+ className={clsx('Icon', styles.abInputIcon)}
158
+ disabled={true}
159
+ expands={false}
160
+ shrinks={false}
161
+ />
162
+ ) : null}
163
+
164
+ <input
165
+ ref={inputRef}
166
+ data-has-value={hasValue ? 'true' : 'false'}
167
+ id={props.id}
168
+ className={clsx(
169
+ 'Input',
170
+ styles.abInputField,
171
+ props.className,
172
+ props.capitalized && styles.capitalize,
173
+ props.indicatorHidden && styles.noMargin,
174
+ )}
175
+ style={{
176
+ width: props.autoresize
177
+ ? computeAutoresizeWidth(props.value, props.autoresizeMultiplier ?? 8)
178
+ : undefined,
179
+ }}
180
+ type={computeType(props.type, Boolean(isPasswordVisible))}
181
+ inputMode={props.inputMode}
182
+ name={props.name}
183
+ minLength={props.minLength}
184
+ maxLength={props.maxLength}
185
+ min={props.min}
186
+ max={props.max}
187
+ pattern={props.pattern?.source}
188
+ placeholder={props.placeholder}
189
+ value={props.value}
190
+ spellCheck={props.spellCheck}
191
+ autoComplete={props.autoComplete ?? 'off'}
192
+ required={props.required}
193
+ readOnly={props.isLoading || props.isReadOnly}
194
+ disabled={props.isDisabled}
195
+ onChange={handleChange}
196
+ onKeyDown={handleKeyDown}
197
+ onKeyUp={handleKeyUp}
198
+ onBlur={props.onBlur}
199
+ onFocus={props.onFocus}
200
+ />
201
+
202
+ {props.isClearable && hasValue ? (
203
+ <AbBalloon title={clearLabel}>
204
+ <AbIconButton
205
+ icon="cancel"
206
+ className={clsx('ClearButton', styles.abInputClear)}
207
+ disabled={false}
208
+ expands={false}
209
+ shrinks={false}
210
+ naked={true}
211
+ tabIndex={-1}
212
+ hidden={props.isLoading}
213
+ onClick={handleClear}
214
+ title={clearLabel}
215
+ />
216
+ </AbBalloon>
217
+ ) : null}
218
+
219
+ {props.hasActionButton && props.actionIcon ? (
220
+ <AbIconButton
221
+ icon={props.actionIcon}
222
+ className={clsx('ActionButton', styles.abInputAction)}
223
+ disabled={false}
224
+ expands={true}
225
+ shrinks={true}
226
+ naked={true}
227
+ href={props.actionLink}
228
+ onClick={props.onAction}
229
+ />
230
+ ) : null}
231
+
232
+ {props.type === 'password' && props.togglePasswordEnabled ? (
233
+ <AbBalloon title={isPasswordVisible ? hidePasswordLabel : showPasswordLabel}>
234
+ <AbIconButton
235
+ className={clsx('TogglePassword', 'icon-button', styles.abInputTogglePassword)}
236
+ icon={isPasswordVisible ? 'visibility_off' : 'visibility'}
237
+ active={Boolean(isPasswordVisible)}
238
+ disabled={false}
239
+ expands={true}
240
+ shrinks={false}
241
+ tabIndex={-1}
242
+ onClick={handlePasswordToggle}
243
+ title={isPasswordVisible ? hidePasswordLabel : showPasswordLabel}
244
+ />
245
+ </AbBalloon>
246
+ ) : null}
247
+
248
+ {props.isLoading ? (
249
+ <span className={clsx('Spinner', 'spinner', 'dots-12', styles.abInputSpinner)} />
250
+ ) : null}
251
+
252
+ <span
253
+ className={clsx(
254
+ 'Indicator',
255
+ 'input-indicator',
256
+ props.hasError && 'error',
257
+ styles.abInputIndicator,
258
+ props.indicatorHidden && styles.hidden,
259
+ )}
260
+ data-no-effect={props.hasError?.toString()}
261
+ >
262
+ <span data-bar="" />
263
+ <span data-val="" />
264
+ </span>
265
+ </div>
266
+
267
+ {props.message ? (
268
+ <p className={clsx(styles.abInputMessage, props.hasError && styles.abInputMessageError)}>
269
+ {props.message}
270
+ </p>
271
+ ) : null}
272
+ </div>
273
+ );
274
+ });
275
+
276
+ export default AbInput;
277
+ export { AbInput };
@@ -0,0 +1,101 @@
1
+ /*
2
+ * @license MIT
3
+ * ~~~~~~~~~~~~
4
+ * ab-nextjs-components
5
+ * ~~~~~~~~~~~~
6
+ * Copyright (c) 2026 Abraham Ukachi. The abElements Project.
7
+ * Plain CSS modules — no Tailwind Tailwind apply (Turbopack-safe).
8
+ */
9
+
10
+
11
+ .abInput {
12
+ display: flex;
13
+ flex-direction: column;
14
+ width: 100%;
15
+ height: auto;
16
+ }
17
+
18
+ .abInputLabel {
19
+ display: block;
20
+ margin-bottom: 0.25rem;
21
+ font-size: 0.875rem;
22
+ }
23
+
24
+ .abInputLabel[data-raised='true'] {
25
+ transform: translateY(-0.125rem);
26
+ }
27
+
28
+ .row {
29
+ display: flex;
30
+ flex-direction: row;
31
+ align-items: center;
32
+ position: relative;
33
+ }
34
+
35
+ .abInputField {
36
+ flex: 1;
37
+ min-width: 0;
38
+ margin-bottom: 0.75rem;
39
+ background: transparent;
40
+ border: none;
41
+ outline: none;
42
+ color: inherit;
43
+ font: inherit;
44
+ }
45
+
46
+ .abInputField::-webkit-search-decoration,
47
+ .abInputField::-webkit-search-cancel-button,
48
+ .abInputField::-webkit-search-results-button,
49
+ .abInputField::-webkit-search-results-decoration {
50
+ display: none;
51
+ appearance: none;
52
+ }
53
+
54
+ .abInputField[type='number'] {
55
+ -moz-appearance: textfield;
56
+ }
57
+
58
+ .capitalize {
59
+ text-transform: capitalize;
60
+ }
61
+
62
+ .noMargin {
63
+ margin-bottom: 0 !important;
64
+ }
65
+
66
+ .abInputIcon,
67
+ .abInputClear,
68
+ .abInputAction,
69
+ .abInputTogglePassword {
70
+ flex-shrink: 0;
71
+ }
72
+
73
+ .abInputSpinner {
74
+ display: inline-block;
75
+ width: 1.25rem;
76
+ height: 1.25rem;
77
+ flex-shrink: 0;
78
+ }
79
+
80
+ .abInputIndicator {
81
+ position: absolute;
82
+ left: 0;
83
+ right: 0;
84
+ bottom: 0;
85
+ height: 2px;
86
+ pointer-events: none;
87
+ }
88
+
89
+ .hidden {
90
+ display: none !important;
91
+ }
92
+
93
+ .abInputMessage {
94
+ margin: 0.25rem 0 0;
95
+ font-size: 0.75rem;
96
+ opacity: 0.8;
97
+ }
98
+
99
+ .abInputMessageError {
100
+ color: var(--error-color, #b3261e);
101
+ }
@@ -0,0 +1,60 @@
1
+ /*
2
+ * @license MIT
3
+ * ~~~~~~~~~~~~
4
+ * ab-nextjs-components
5
+ * ~~~~~~~~~~~~
6
+ * Copyright (c) 2026 Abraham Ukachi. The abElements Project.
7
+ *
8
+ * @project: ab-nextjs-components
9
+ * @name: AbKeyFeature - Client (port of lyd-key-feature)
10
+ * @file: ab-key-feature/index.tsx
11
+ */
12
+
13
+ 'use client';
14
+
15
+ import type { ReactElement } from 'react';
16
+ import Link from 'next/link';
17
+ import clsx from 'clsx';
18
+ import styles from './styles.module.css';
19
+
20
+ export interface AbKeyFeatureProps {
21
+ className?: string;
22
+ hidden?: boolean;
23
+ icon: string;
24
+ name: string;
25
+ title: string;
26
+ description?: string;
27
+ linkHidden?: boolean;
28
+ linkLabel?: string;
29
+ link?: string;
30
+ watermark?: string;
31
+ }
32
+
33
+ const AbKeyFeature = ({
34
+ className,
35
+ hidden = false,
36
+ icon,
37
+ name,
38
+ title,
39
+ description,
40
+ linkHidden = false,
41
+ linkLabel = 'Learn more',
42
+ link,
43
+ watermark,
44
+ }: AbKeyFeatureProps): ReactElement => {
45
+ return (
46
+ <article hidden={hidden} className={clsx('AbKeyFeature', styles.abKeyFeature, className)}>
47
+ <span className={clsx('material-symbols-rounded', styles.abKeyFeatureIcon)} aria-hidden>{icon}</span>
48
+ <span className={styles.abKeyFeatureName}>{name}</span>
49
+ <h3 className={styles.abKeyFeatureTitle}>{title}</h3>
50
+ {description ? <p className={styles.abKeyFeatureDesc}>{description}</p> : null}
51
+ {!linkHidden && link ? (
52
+ <Link href={link} className={styles.abKeyFeatureLink}>{linkLabel}</Link>
53
+ ) : null}
54
+ {watermark ? <span className={styles.abKeyFeatureWatermark} aria-hidden>{watermark}</span> : null}
55
+ </article>
56
+ );
57
+ };
58
+
59
+ export default AbKeyFeature;
60
+ export { AbKeyFeature };
@@ -0,0 +1,47 @@
1
+ .abKeyFeature {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 0.5rem;
5
+ padding: 1rem;
6
+ border-radius: 1rem;
7
+ position: relative;
8
+ overflow: hidden;
9
+ background: color-mix(in srgb, var(--ab-surface-container, #f3edf7) 85%, transparent);
10
+ }
11
+ .abKeyFeatureIcon {
12
+ font-size: 2rem;
13
+ color: var(--ab-primary, #6750a4);
14
+ }
15
+ .abKeyFeatureName {
16
+ font-size: 0.75rem;
17
+ font-weight: 700;
18
+ text-transform: uppercase;
19
+ letter-spacing: 0.06em;
20
+ opacity: 0.7;
21
+ }
22
+ .abKeyFeatureTitle {
23
+ margin: 0;
24
+ font-size: 1.25rem;
25
+ font-weight: 700;
26
+ }
27
+ .abKeyFeatureDesc {
28
+ margin: 0;
29
+ font-size: 0.9375rem;
30
+ opacity: 0.8;
31
+ }
32
+ .abKeyFeatureLink {
33
+ margin-top: 0.5rem;
34
+ align-self: flex-start;
35
+ color: var(--ab-primary, #6750a4);
36
+ font-weight: 600;
37
+ text-decoration: none;
38
+ }
39
+ .abKeyFeatureWatermark {
40
+ position: absolute;
41
+ right: -0.5rem;
42
+ bottom: -0.5rem;
43
+ font-size: 5rem;
44
+ opacity: 0.06;
45
+ pointer-events: none;
46
+ user-select: none;
47
+ }
@@ -0,0 +1,116 @@
1
+ /*
2
+ * @license MIT
3
+ * ~~~~~~~~~~~~
4
+ * ab-nextjs-components
5
+ * ~~~~~~~~~~~~
6
+ * Copyright (c) 2026 Abraham Ukachi. The abElements Project.
7
+ *
8
+ * @project: ab-nextjs-components
9
+ * @name: AbLikeButton - Client (port of lyd-like-button)
10
+ * @file: ab-like-button/index.tsx
11
+ */
12
+
13
+ 'use client';
14
+
15
+ import type { CSSProperties, MouseEvent, ReactElement } from 'react';
16
+ import Link from 'next/link';
17
+ import clsx from 'clsx';
18
+ import styles from './styles.module.css';
19
+
20
+ export interface AbLikeButtonProps {
21
+ className?: string;
22
+ style?: CSSProperties;
23
+ hidden?: boolean;
24
+ liked?: boolean;
25
+ disabled?: boolean;
26
+ expands?: boolean;
27
+ shrinks?: boolean;
28
+ tabIndex?: number;
29
+ onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
30
+ href?: string | null;
31
+ isActive?: boolean;
32
+ isNaked?: boolean;
33
+ isLoading?: boolean;
34
+ likeIcon?: string;
35
+ likedIcon?: string;
36
+ ariaLabel?: string;
37
+ }
38
+
39
+ const AbLikeButton = ({
40
+ className,
41
+ style,
42
+ hidden = false,
43
+ liked = false,
44
+ disabled = false,
45
+ expands = false,
46
+ shrinks = false,
47
+ tabIndex,
48
+ onClick,
49
+ href = null,
50
+ isActive = false,
51
+ isNaked = false,
52
+ isLoading = false,
53
+ likeIcon = 'favorite_border',
54
+ likedIcon = 'favorite',
55
+ ariaLabel = 'Like',
56
+ }: AbLikeButtonProps): ReactElement => {
57
+ const icon = liked ? likedIcon : likeIcon;
58
+ const classNames = clsx(
59
+ 'AbLikeButton',
60
+ styles.abLikeButton,
61
+ liked && styles.isLiked,
62
+ isActive && styles.isActive,
63
+ isNaked && styles.isNaked,
64
+ expands && styles.expands,
65
+ shrinks && styles.shrinks,
66
+ disabled && styles.isDisabled,
67
+ isLoading && styles.isLoading,
68
+ className,
69
+ );
70
+
71
+ const inner = isLoading ? (
72
+ <span className={styles.abLikeButtonSpinner} aria-hidden />
73
+ ) : (
74
+ <span className={clsx('material-symbols-rounded', styles.abLikeButtonIcon)} aria-hidden>
75
+ {icon}
76
+ </span>
77
+ );
78
+
79
+ if (href && !disabled) {
80
+ return (
81
+ <Link
82
+ href={href}
83
+ hidden={hidden}
84
+ style={style}
85
+ className={classNames}
86
+ aria-label={ariaLabel}
87
+ data-liked={liked ? 'true' : 'false'}
88
+ data-naked={isNaked ? 'true' : 'false'}
89
+ tabIndex={tabIndex}
90
+ >
91
+ {inner}
92
+ </Link>
93
+ );
94
+ }
95
+
96
+ return (
97
+ <button
98
+ type="button"
99
+ hidden={hidden}
100
+ style={style}
101
+ className={classNames}
102
+ disabled={disabled || isLoading}
103
+ aria-label={ariaLabel}
104
+ aria-pressed={liked}
105
+ data-liked={liked ? 'true' : 'false'}
106
+ data-naked={isNaked ? 'true' : 'false'}
107
+ tabIndex={tabIndex}
108
+ onClick={onClick}
109
+ >
110
+ {inner}
111
+ </button>
112
+ );
113
+ };
114
+
115
+ export default AbLikeButton;
116
+ export { AbLikeButton };
@@ -0,0 +1,60 @@
1
+ .abLikeButton {
2
+ position: relative;
3
+ display: inline-flex;
4
+ align-items: center;
5
+ justify-content: center;
6
+ width: fit-content;
7
+ height: fit-content;
8
+ padding: 0.5rem;
9
+ overflow: hidden;
10
+ border: none;
11
+ border-radius: 9999px;
12
+ background: transparent;
13
+ color: inherit;
14
+ text-decoration: none;
15
+ cursor: pointer;
16
+ transition: transform 0.2s ease, background 0.2s ease;
17
+ }
18
+
19
+ .abLikeButton:hover:not(.isDisabled):not(.isNaked) {
20
+ background: color-mix(in srgb, var(--ab-outline, #79747e) 12%, transparent);
21
+ }
22
+
23
+ .abLikeButtonIcon {
24
+ font-size: 1.5rem;
25
+ line-height: 1;
26
+ }
27
+
28
+ .isLiked .abLikeButtonIcon {
29
+ color: #e11d48;
30
+ font-variation-settings: 'FILL' 1;
31
+ }
32
+
33
+ .isActive {
34
+ outline: 2px solid var(--ab-primary, #a67c52);
35
+ }
36
+
37
+ .isNaked:hover {
38
+ background: transparent;
39
+ }
40
+
41
+ .isDisabled {
42
+ opacity: 0.5;
43
+ pointer-events: none;
44
+ }
45
+
46
+ .expands { width: 100%; }
47
+ .shrinks { width: fit-content; }
48
+
49
+ .abLikeButtonSpinner {
50
+ width: 1.25rem;
51
+ height: 1.25rem;
52
+ border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
53
+ border-top-color: currentColor;
54
+ border-radius: 9999px;
55
+ animation: abLikeSpin 0.7s linear infinite;
56
+ }
57
+
58
+ @keyframes abLikeSpin {
59
+ to { transform: rotate(360deg); }
60
+ }