@usefui/components 1.5.3 → 1.7.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.d.mts +615 -51
  3. package/dist/index.d.ts +615 -51
  4. package/dist/index.js +3154 -660
  5. package/dist/index.mjs +3131 -661
  6. package/package.json +12 -12
  7. package/src/__tests__/Avatar.test.tsx +55 -55
  8. package/src/__tests__/MessageBubble.test.tsx +179 -0
  9. package/src/__tests__/Shimmer.test.tsx +122 -0
  10. package/src/__tests__/Tree.test.tsx +275 -0
  11. package/src/accordion/Accordion.stories.tsx +6 -4
  12. package/src/accordion/hooks/index.tsx +3 -1
  13. package/src/accordion/index.tsx +1 -2
  14. package/src/avatar/Avatar.stories.tsx +37 -7
  15. package/src/avatar/index.tsx +90 -19
  16. package/src/avatar/styles/index.ts +58 -12
  17. package/src/badge/Badge.stories.tsx +27 -5
  18. package/src/badge/index.tsx +21 -14
  19. package/src/badge/styles/index.ts +69 -40
  20. package/src/button/Button.stories.tsx +40 -27
  21. package/src/button/index.tsx +13 -9
  22. package/src/button/styles/index.ts +308 -47
  23. package/src/card/index.tsx +2 -4
  24. package/src/checkbox/Checkbox.stories.tsx +72 -33
  25. package/src/checkbox/hooks/index.tsx +5 -1
  26. package/src/checkbox/index.tsx +8 -6
  27. package/src/checkbox/styles/index.ts +239 -19
  28. package/src/collapsible/Collapsible.stories.tsx +6 -4
  29. package/src/collapsible/hooks/index.tsx +3 -1
  30. package/src/dialog/Dialog.stories.tsx +173 -31
  31. package/src/dialog/hooks/index.tsx +5 -1
  32. package/src/dialog/styles/index.ts +15 -8
  33. package/src/dropdown/Dropdown.stories.tsx +61 -23
  34. package/src/dropdown/hooks/index.tsx +3 -1
  35. package/src/dropdown/index.tsx +51 -40
  36. package/src/dropdown/styles/index.ts +30 -19
  37. package/src/field/Field.stories.tsx +183 -24
  38. package/src/field/hooks/index.tsx +5 -1
  39. package/src/field/index.tsx +930 -13
  40. package/src/field/styles/index.ts +246 -14
  41. package/src/field/types/index.ts +31 -0
  42. package/src/field/utils/index.ts +201 -0
  43. package/src/index.ts +8 -1
  44. package/src/message-bubble/MessageBubble.stories.tsx +138 -0
  45. package/src/message-bubble/hooks/index.tsx +41 -0
  46. package/src/message-bubble/index.tsx +171 -0
  47. package/src/message-bubble/styles/index.ts +58 -0
  48. package/src/otp-field/OTPField.stories.tsx +22 -24
  49. package/src/otp-field/hooks/index.tsx +3 -1
  50. package/src/otp-field/index.tsx +14 -3
  51. package/src/otp-field/styles/index.ts +114 -16
  52. package/src/otp-field/types/index.ts +9 -1
  53. package/src/overlay/styles/index.ts +1 -0
  54. package/src/ruler/Ruler.stories.tsx +43 -0
  55. package/src/ruler/constants/index.ts +3 -0
  56. package/src/ruler/hooks/index.tsx +53 -0
  57. package/src/ruler/index.tsx +239 -0
  58. package/src/ruler/styles/index.tsx +154 -0
  59. package/src/ruler/types/index.ts +17 -0
  60. package/src/select/Select.stories.tsx +91 -0
  61. package/src/select/hooks/index.tsx +71 -0
  62. package/src/select/index.tsx +331 -0
  63. package/src/select/styles/index.tsx +156 -0
  64. package/src/sheet/hooks/index.tsx +5 -1
  65. package/src/shimmer/Shimmer.stories.tsx +97 -0
  66. package/src/shimmer/index.tsx +64 -0
  67. package/src/shimmer/styles/index.ts +33 -0
  68. package/src/skeleton/index.tsx +7 -6
  69. package/src/spinner/Spinner.stories.tsx +29 -4
  70. package/src/spinner/index.tsx +16 -6
  71. package/src/spinner/styles/index.ts +41 -22
  72. package/src/switch/Switch.stories.tsx +46 -17
  73. package/src/switch/hooks/index.tsx +5 -1
  74. package/src/switch/index.tsx +5 -8
  75. package/src/switch/styles/index.ts +45 -45
  76. package/src/tabs/Tabs.stories.tsx +43 -15
  77. package/src/tabs/hooks/index.tsx +5 -1
  78. package/src/text-area/Textarea.stories.tsx +45 -8
  79. package/src/text-area/index.tsx +9 -6
  80. package/src/text-area/styles/index.ts +1 -1
  81. package/src/toggle/Toggle.stories.tsx +6 -4
  82. package/src/toolbar/hooks/index.tsx +5 -1
  83. package/src/tree/Tree.stories.tsx +141 -0
  84. package/src/tree/hooks/tree-node-provider.tsx +50 -0
  85. package/src/tree/hooks/tree-provider.tsx +75 -0
  86. package/src/tree/index.tsx +231 -0
  87. package/src/tree/styles/index.ts +23 -0
  88. package/tsconfig.build.json +20 -0
  89. package/tsconfig.json +1 -3
  90. package/src/privacy-field/PrivacyField.stories.tsx +0 -29
  91. package/src/privacy-field/index.tsx +0 -56
  92. package/src/privacy-field/styles/index.ts +0 -17
@@ -0,0 +1,331 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ import { useClickOutside } from "@usefui/hooks";
6
+ import { SelectProvider, useSelect } from "./hooks";
7
+
8
+ import { Wrapper, Trigger, Label, Content, List, Item } from "./styles";
9
+ import { ScrollArea } from "../scrollarea";
10
+
11
+ import { applyDataState } from "../utils";
12
+ import { IButtonProperties } from "../button";
13
+ import {
14
+ IComponentStyling,
15
+ ComponentVariantEnum,
16
+ ComponentShapeEnum,
17
+ ComponentSizeEnum,
18
+ } from "../../../../types";
19
+
20
+ export interface ISelectTriggerProperties extends IButtonProperties {
21
+ error?: boolean;
22
+ children?: React.ReactNode;
23
+ }
24
+
25
+ export interface ISelectContentProperties
26
+ extends IComponentStyling, React.ComponentPropsWithRef<"ul"> {
27
+ defaultOpen?: boolean;
28
+ }
29
+
30
+ export interface ISelectItemProperties
31
+ extends IComponentStyling, Omit<React.ComponentProps<"li">, "onClick"> {
32
+ value?: string;
33
+ disabled?: boolean;
34
+ onClick?: (
35
+ event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
36
+ ) => void;
37
+ }
38
+
39
+ const SelectRoot = ({ children }: { children: React.ReactElement }) => {
40
+ return <SelectProvider>{children}</SelectProvider>;
41
+ };
42
+ SelectRoot.displayName = "Select.Root";
43
+
44
+ /**
45
+ * Select is used to allow users to choose a single value from a list of options.
46
+ *
47
+ * **Best practices:**
48
+ *
49
+ * - Use a clear and descriptive label for the trigger that accurately conveys he purpose of the select.
50
+ * - Ensure that the select can be opened and closed using the keyboard.
51
+ * - Ensure that the select is dismissed when the user clicks outside of it or presses the Esc key.
52
+ * - Wrap this component with `Select.Root` to provide the necessary context.
53
+ *
54
+ * @param {React.ComponentProps<"div">} props - The props for the Select component.
55
+ * @param {ReactNode} props.children - The content to be rendered inside the select.
56
+ * @returns {ReactElement} The Select component.
57
+ */
58
+ const Select = ({ children }: React.ComponentProps<"div">) => {
59
+ const selectRef = React.useRef<HTMLDivElement | null>(null);
60
+ const { states, methods } = useSelect();
61
+
62
+ const handleClickOutside = () => {
63
+ if (states.open && methods.setOpen) {
64
+ methods.setOpen(false);
65
+ }
66
+ };
67
+
68
+ useClickOutside(
69
+ selectRef as React.RefObject<HTMLElement>,
70
+ handleClickOutside,
71
+ );
72
+
73
+ return <Wrapper ref={selectRef}>{children}</Wrapper>;
74
+ };
75
+ Select.displayName = "Select";
76
+
77
+ /**
78
+ * Select.Trigger is used to control the expansion and collapse of the associated Select.Content component.
79
+ *
80
+ * **Best practices:**
81
+ *
82
+ * - Use a clear and descriptive label that accurately conveys the purpose of the select field.
83
+ * - Ensure that the trigger can be operated using only the keyboard.
84
+ * - Ensure that the focus is properly managed when the trigger is activated.
85
+ * - Use the `error` prop to indicate a validation error state.
86
+ *
87
+ * @param {ISelectTriggerProperties} props - The props for the Select.Trigger component.
88
+ * @param {ComponentVariantEnum} props.variant - The visual variant of the trigger. Defaults to "secondary".
89
+ * @param {ComponentShapeEnum} props.shape - The shape of the trigger. Defaults to "smooth".
90
+ * @param {ComponentSizeEnum} props.sizing - The size of the trigger. Defaults to "medium".
91
+ * @param {boolean} props.error - Whether the trigger should display an error state. Defaults to false.
92
+ * @param {boolean} props.disabled - Whether the trigger is disabled.
93
+ * @param {boolean} props.raw - Define whether the component is styled or not.
94
+ * @param {ReactNode} props.children - The content to be rendered inside the trigger.
95
+ * @returns {ReactElement} The Select.Trigger component.
96
+ */
97
+ const SelectTrigger = (props: ISelectTriggerProperties) => {
98
+ const {
99
+ raw,
100
+ variant,
101
+ shape,
102
+ sizing,
103
+ error = false,
104
+ disabled,
105
+ children,
106
+ ...restProps
107
+ } = props;
108
+
109
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
110
+ const triggerRect = () => triggerRef.current?.getBoundingClientRect();
111
+
112
+ const { id, states, methods } = useSelect();
113
+ const { toggleOpen, setTriggerProps } = methods;
114
+
115
+ const handleClick = () => {
116
+ if (disabled) return;
117
+ if (toggleOpen) toggleOpen();
118
+ if (setTriggerProps) {
119
+ setTriggerProps({
120
+ top: Number(triggerRect()?.top),
121
+ right: Number(triggerRect()?.right),
122
+ bottom: Number(triggerRect()?.bottom),
123
+ left: Number(triggerRect()?.left),
124
+ width: Number(triggerRect()?.width),
125
+ height: Number(triggerRect()?.height),
126
+ });
127
+ }
128
+ };
129
+
130
+ return (
131
+ <Trigger
132
+ ref={triggerRef}
133
+ type="button"
134
+ role="combobox"
135
+ id={id.split("|").at(0)}
136
+ onClick={handleClick}
137
+ aria-haspopup="listbox"
138
+ aria-expanded={Boolean(states.open)}
139
+ aria-controls={id.split("|").at(-1)}
140
+ data-state={states.open ? "open" : "closed"}
141
+ data-variant={variant ?? ComponentVariantEnum.Secondary}
142
+ data-shape={shape ?? ComponentShapeEnum.Smooth}
143
+ data-size={sizing ?? ComponentSizeEnum.Medium}
144
+ data-error={error}
145
+ data-raw={Boolean(raw)}
146
+ disabled={disabled}
147
+ {...restProps}
148
+ >
149
+ <Label>{children}</Label>
150
+ </Trigger>
151
+ );
152
+ };
153
+ SelectTrigger.displayName = "Select.Trigger";
154
+
155
+ /**
156
+ * Select.Content contains the list of options associated with the Select.Trigger component.
157
+ *
158
+ * **Best practices:**
159
+ *
160
+ * - Ensure that the content is hidden when the select is collapsed.
161
+ * - Ensure that the content is properly positioned relative to the trigger,
162
+ * accounting for available viewport space.
163
+ * - Ensure that the content can be dismissed using the Esc key.
164
+ *
165
+ * @param {ISelectContentProperties} props - The props for the Select.Content component.
166
+ * @param {boolean} props.raw - Define whether the component is styled or not.
167
+ * @param {boolean} props.defaultOpen - The initial open state of the select. Defaults to false.
168
+ * @param {ReactNode} props.children - The list of Select.Item components to render.
169
+ * @returns {ReactElement} The Select.Content component.
170
+ */
171
+ const SelectContent = (props: ISelectContentProperties) => {
172
+ const { raw, defaultOpen, children, ...restProps } = props;
173
+ const { id, states, methods } = useSelect();
174
+ const { toggleOpen, setContentProps } = methods;
175
+
176
+ const mounted = React.useRef(false);
177
+ const contentRef = React.useRef<HTMLDivElement>(null);
178
+
179
+ const contentRect = () => contentRef?.current?.getBoundingClientRect();
180
+ const bodyRect = (): DOMRect | undefined => {
181
+ if (typeof document !== "undefined") {
182
+ return document?.body?.getBoundingClientRect();
183
+ }
184
+ return undefined;
185
+ };
186
+
187
+ const positions = {
188
+ btt: `calc((${states?.triggerProps?.top}px - ${states?.contentProps?.height}px) - (var(--measurement-medium-10) * 2))`,
189
+ ttb: `calc((${states?.triggerProps?.top}px + ${states?.triggerProps?.height}px) + var(--measurement-medium-10))`,
190
+ };
191
+
192
+ const dimensions = {
193
+ body_height: bodyRect()?.height ?? 0,
194
+ content_height: states.contentProps.height,
195
+ content_bottom: states.contentProps.bottom,
196
+ };
197
+
198
+ const hasEnoughVerticalSpace =
199
+ dimensions.body_height - dimensions.content_bottom >
200
+ dimensions.content_height - dimensions.content_height * 0.9;
201
+
202
+ React.useEffect(() => {
203
+ if (defaultOpen && toggleOpen) toggleOpen();
204
+ // eslint-disable-next-line react-hooks/exhaustive-deps
205
+ }, []);
206
+
207
+ React.useEffect(() => {
208
+ mounted.current = true;
209
+
210
+ if (setContentProps) {
211
+ setContentProps({
212
+ top: Number(contentRect()?.top),
213
+ right: Number(contentRect()?.right),
214
+ bottom: Number(contentRect()?.bottom),
215
+ left: Number(contentRect()?.left),
216
+ width: Number(contentRect()?.width),
217
+ height: Number(contentRect()?.height),
218
+ });
219
+ }
220
+
221
+ return () => {
222
+ mounted.current = false;
223
+ };
224
+ // eslint-disable-next-line react-hooks/exhaustive-deps
225
+ }, [states.open]);
226
+
227
+ React.useEffect(() => {
228
+ if (!states.open) return;
229
+
230
+ const handleKeyDown = (event: KeyboardEvent) => {
231
+ if (event.key === "Escape" && methods.setOpen) {
232
+ methods.setOpen(false);
233
+ }
234
+ };
235
+
236
+ document.addEventListener("keydown", handleKeyDown);
237
+ return () => document.removeEventListener("keydown", handleKeyDown);
238
+ // eslint-disable-next-line react-hooks/exhaustive-deps
239
+ }, [states.open]);
240
+
241
+ if (!states.open) return null;
242
+ return (
243
+ <ScrollArea
244
+ scrollbar
245
+ as={Content}
246
+ ref={contentRef}
247
+ id={id.split("|").at(-1)}
248
+ role="listbox"
249
+ tabIndex={-1}
250
+ aria-labelledby={id.split("|").at(0)}
251
+ data-state={applyDataState(Boolean(states.open))}
252
+ data-side={hasEnoughVerticalSpace ? "bottom" : "top"}
253
+ data-raw={Boolean(raw)}
254
+ style={{
255
+ top: hasEnoughVerticalSpace ? positions.ttb : positions.btt,
256
+ left: `${states?.triggerProps?.left}px`,
257
+ width: `${states?.triggerProps?.width}px`,
258
+ }}
259
+ {...restProps}
260
+ >
261
+ {children}
262
+ </ScrollArea>
263
+ );
264
+ };
265
+ SelectContent.displayName = "Select.Content";
266
+
267
+ /**
268
+ * Select.Item represents a single option within Select.Content.
269
+ *
270
+ * **Best practices:**
271
+ *
272
+ * - Use a clear and concise label that accurately describes the option.
273
+ * - Ensure that the item can be selected using only the keyboard (Space or Enter).
274
+ * - Use the `disabled` prop to prevent selection of unavailable options.
275
+ * - Provide a meaningful `value` prop that will be stored in the select state upon selection.
276
+ *
277
+ * @param {ISelectItemProperties} props - The props for the Select.Item component.
278
+ * @param {string} props.value - The value associated with this option, stored in the select state on selection.
279
+ * @param {boolean} props.disabled - Whether the item is disabled and cannot be selected. Defaults to false.
280
+ * @param {boolean} props.raw - Define whether the component is styled or not.
281
+ * @param {Function} props.onClick - Optional callback fired when the item is selected via click or keyboard.
282
+ * @param {ReactNode} props.children - The content to be rendered inside the item.
283
+ * @returns {ReactElement} The Select.Item component.
284
+ */
285
+ const SelectItem = (props: ISelectItemProperties) => {
286
+ const { raw, value, disabled, onClick, children, ...restProps } = props;
287
+ const { states, methods } = useSelect();
288
+
289
+ const isSelected = states?.value === value;
290
+ const handleSelect = (
291
+ event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
292
+ ) => {
293
+ if (disabled) return;
294
+
295
+ if (methods.setValue) methods.setValue(value);
296
+ if (methods.setOpen) methods.setOpen(false);
297
+ if (onClick) onClick(event);
298
+ };
299
+
300
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLLIElement>) => {
301
+ if (["Space", "Enter"].includes(event.code || event.key) && !disabled) {
302
+ event.preventDefault();
303
+ handleSelect(event);
304
+ }
305
+ };
306
+
307
+ return (
308
+ <List
309
+ role="option"
310
+ tabIndex={0}
311
+ aria-disabled={disabled}
312
+ aria-selected={isSelected}
313
+ data-orientation="vertical"
314
+ data-selected={isSelected}
315
+ data-raw={Boolean(raw)}
316
+ onClick={handleSelect}
317
+ onKeyDown={handleKeyDown}
318
+ {...restProps}
319
+ >
320
+ <Item>{children}</Item>
321
+ </List>
322
+ );
323
+ };
324
+ SelectItem.displayName = "Select.Item";
325
+
326
+ Select.Root = SelectRoot;
327
+ Select.Trigger = SelectTrigger;
328
+ Select.Content = SelectContent;
329
+ Select.Item = SelectItem;
330
+
331
+ export { SelectRoot, Select, SelectTrigger, SelectContent, SelectItem };
@@ -0,0 +1,156 @@
1
+ // select/styles.ts
2
+ import styled from "styled-components";
3
+ import {
4
+ FieldDefaultStyles,
5
+ FieldVariantsStyles,
6
+ FieldSizeStyles,
7
+ FieldShapeStyles,
8
+ } from "../../field/styles";
9
+
10
+ export const Wrapper = styled.div`
11
+ position: relative;
12
+ width: 100%;
13
+ `;
14
+
15
+ export const Trigger = styled.button<any>`
16
+ all: unset;
17
+ box-sizing: border-box;
18
+
19
+ position: relative;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: space-between;
23
+ gap: var(--measurement-medium-10);
24
+
25
+ ${FieldDefaultStyles}
26
+ ${FieldVariantsStyles}
27
+ ${FieldShapeStyles}
28
+ ${FieldSizeStyles}
29
+
30
+
31
+ cursor: pointer !important;
32
+ `;
33
+
34
+ export const Label = styled.span<any>`
35
+ flex: 1;
36
+ text-align: left;
37
+ font-weight: 400;
38
+ white-space: nowrap;
39
+ overflow: hidden;
40
+ text-overflow: ellipsis;
41
+ `;
42
+
43
+ export const Content = styled.ul<any>`
44
+ @keyframes select-slide-in-down {
45
+ 0% {
46
+ opacity: 0;
47
+ transform: translateY(calc(var(--measurement-small-60) * -1));
48
+ }
49
+ 100% {
50
+ opacity: 1;
51
+ transform: translateY(0);
52
+ }
53
+ }
54
+
55
+ @keyframes select-slide-in-up {
56
+ 0% {
57
+ opacity: 0;
58
+ transform: translateY(var(--measurement-small-60));
59
+ }
60
+ 100% {
61
+ opacity: 1;
62
+ transform: translateY(0);
63
+ }
64
+ }
65
+
66
+ &[data-raw="false"] {
67
+ position: fixed;
68
+ margin: 0;
69
+ box-sizing: border-box;
70
+
71
+ padding: var(--measurement-medium-30);
72
+
73
+ list-style: none;
74
+
75
+ background-color: var(--body-color);
76
+ border: var(--measurement-small-10) solid var(--font-color-alpha-10);
77
+ border-radius: var(--measurement-medium-30);
78
+
79
+ z-index: var(--depth-default-100);
80
+
81
+ height: auto;
82
+ max-height: var(--measurement-large-90);
83
+ overflow-y: auto;
84
+
85
+ animation-duration: 0.2s;
86
+ animation-fill-mode: backwards;
87
+
88
+ &[data-side="bottom"] {
89
+ animation-name: select-slide-in-down;
90
+ }
91
+
92
+ &[data-side="top"] {
93
+ animation-name: select-slide-in-up;
94
+ }
95
+ }
96
+ `;
97
+
98
+ export const List = styled.li<any>`
99
+ list-style: none;
100
+ padding: 0;
101
+ margin: 0;
102
+ user-select: none;
103
+
104
+ &[data-raw="false"] {
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: space-between;
108
+ gap: var(--measurement-medium-10);
109
+
110
+ padding: var(--measurement-medium-30);
111
+ border-radius: var(--measurement-medium-20);
112
+
113
+ text-align: left;
114
+
115
+ color: var(--font-color);
116
+
117
+ outline: none;
118
+ cursor: pointer;
119
+
120
+ transition: all ease-in-out 0.2s;
121
+
122
+ &:hover,
123
+ &:focus,
124
+ &:active,
125
+ &:focus-within,
126
+ &:has(:active) {
127
+ background-color: var(--contrast-color);
128
+ }
129
+
130
+ &[data-selected="true"] {
131
+ color: var(--font-color);
132
+ background-color: var(--contrast-color);
133
+
134
+ &:hover,
135
+ &:focus,
136
+ &:active {
137
+ background-color: var(--font-color-alpha-10);
138
+ }
139
+ }
140
+ }
141
+
142
+ &[aria-disabled="true"] {
143
+ cursor: not-allowed;
144
+ opacity: 0.6;
145
+ }
146
+ `;
147
+
148
+ export const Item = styled.span`
149
+ display: flex;
150
+ align-items: center;
151
+ gap: var(--measurement-small-60);
152
+ flex: 1;
153
+ white-space: nowrap;
154
+ overflow: hidden;
155
+ text-overflow: ellipsis;
156
+ `;
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import React from "react";
2
4
  import { IReactChildren } from "../../../../../types";
3
5
 
@@ -15,7 +17,9 @@ const SheetContext = React.createContext<IContextProperties>({
15
17
  });
16
18
  export const useSheet = () => React.useContext(SheetContext);
17
19
 
18
- export const SheetProvider = ({ children }: IReactChildren): JSX.Element => {
20
+ export const SheetProvider = ({
21
+ children,
22
+ }: IReactChildren): React.JSX.Element => {
19
23
  const context = useSheetProvider();
20
24
 
21
25
  return (
@@ -0,0 +1,97 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+
4
+ import { Page, Shimmer } from "..";
5
+
6
+ const meta = {
7
+ title: "Components/Shimmer",
8
+ component: Shimmer,
9
+ tags: ["autodocs"],
10
+ decorators: [
11
+ (Story) => (
12
+ <Page>
13
+ <Page.Content className="p-medium-30">
14
+ <Story />
15
+ </Page.Content>
16
+ </Page>
17
+ ),
18
+ ],
19
+ } satisfies Meta<typeof Shimmer>;
20
+ export default meta;
21
+
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ export const Default: Story = {
25
+ args: {
26
+ children: "Loading your content…",
27
+ duration: 2,
28
+ spread: 200,
29
+ shimmerColor: "var(--font-color-alpha-60)",
30
+ baseColor: "var(--font-color-alpha-30)",
31
+ },
32
+ argTypes: {
33
+ duration: {
34
+ control: { type: "number", min: 0.5, max: 10, step: 0.5 },
35
+ },
36
+ spread: {
37
+ control: { type: "number", min: 100, max: 500, step: 50 },
38
+ },
39
+ shimmerColor: {
40
+ control: { type: "color" },
41
+ },
42
+ baseColor: {
43
+ control: { type: "color" },
44
+ },
45
+ },
46
+ render: ({ ...args }) => <Shimmer {...args} />,
47
+ };
48
+
49
+ export const SlowAnimation: Story = {
50
+ render: ({ ...args }) => (
51
+ <Shimmer duration={6} spread={200}>
52
+ Slowly shimmering text…
53
+ </Shimmer>
54
+ ),
55
+ };
56
+
57
+ export const FastAnimation: Story = {
58
+ render: ({ ...args }) => (
59
+ <Shimmer duration={0.8} spread={200}>
60
+ Rapidly shimmering text…
61
+ </Shimmer>
62
+ ),
63
+ };
64
+
65
+ export const WideSpread: Story = {
66
+ render: ({ ...args }) => (
67
+ <Shimmer spread={400}>Wide spread shimmer effect</Shimmer>
68
+ ),
69
+ };
70
+
71
+ export const CustomColors: Story = {
72
+ render: ({ ...args }) => (
73
+ <Shimmer
74
+ shimmerColor="var(--alpha-blue-80)"
75
+ baseColor="var(--alpha-blue-30)"
76
+ >
77
+ Custom branded shimmer
78
+ </Shimmer>
79
+ ),
80
+ };
81
+
82
+ export const Group: Story = {
83
+ render: ({ ...args }) => (
84
+ <React.Fragment>
85
+ {[
86
+ "Fetching user profile…",
87
+ "Loading dashboard…",
88
+ "Syncing your data…",
89
+ "Preparing your workspace…",
90
+ ].map((label) => (
91
+ <div key={label} className="m-b-medium-30">
92
+ <Shimmer>{label}</Shimmer>
93
+ </div>
94
+ ))}
95
+ </React.Fragment>
96
+ ),
97
+ };
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { TextShimmerWrapper } from "./styles";
5
+ import { IComponentStyling } from "../../../../types";
6
+
7
+ const DEFAULT_DURATION = 2;
8
+ const DEFAULT_SPREAD = 200;
9
+ const DEFAULT_SHIMMER_COLOR = "var(--font-color-alpha-60)";
10
+ const DEFAULT_BASE_COLOR = "var(--font-color-alpha-30)";
11
+
12
+ export interface ITextShimmerProperties
13
+ extends IComponentStyling, React.HTMLAttributes<HTMLSpanElement> {
14
+ duration?: number;
15
+ spread?: number;
16
+ shimmerColor?: string;
17
+ baseColor?: string;
18
+ }
19
+
20
+ /**
21
+ * Shimmer applies an animated shimmer gradient effect to inline text content.
22
+ *
23
+ * **Best practices:**
24
+ *
25
+ * - Use to indicate loading states for text content.
26
+ * - Prefer CSS custom properties for `shimmerColor` and `baseColor` to stay consistent with your design tokens.
27
+ * - Avoid using on large blocks of text; favour short labels or headings.
28
+ *
29
+ * @param {ITextShimmerProperties} props - The props for the Shimmer component.
30
+ * @param {ReactNode} props.children - The text content to apply the shimmer effect to.
31
+ * @param {boolean} props.raw - Whether the component is unstyled.
32
+ * @param {number} props.duration - Animation cycle duration in seconds. Defaults to 2.
33
+ * @param {number} props.spread - Gradient spread width as a percentage. Defaults to 200.
34
+ * @param {string} props.shimmerColor - Highlight color of the shimmer. Defaults to `--font-color-alpha-60`.
35
+ * @param {string} props.baseColor - Base text gradient color. Defaults to `--font-color-alpha-30`.
36
+ * @returns {ReactElement} The Shimmer component.
37
+ */
38
+ export const Shimmer = (props: ITextShimmerProperties) => {
39
+ const {
40
+ children,
41
+ raw,
42
+ duration = DEFAULT_DURATION,
43
+ spread = DEFAULT_SPREAD,
44
+ shimmerColor = DEFAULT_SHIMMER_COLOR,
45
+ baseColor = DEFAULT_BASE_COLOR,
46
+ ...restProps
47
+ } = props;
48
+
49
+ return (
50
+ <TextShimmerWrapper
51
+ data-raw={Boolean(raw)}
52
+ data-duration={duration}
53
+ data-spread={spread}
54
+ data-shimmer-color={shimmerColor}
55
+ data-base-color={baseColor}
56
+ aria-label={restProps["aria-label"] ?? "shimmer-text"}
57
+ {...restProps}
58
+ >
59
+ {children}
60
+ </TextShimmerWrapper>
61
+ );
62
+ };
63
+
64
+ Shimmer.displayName = "Shimmer";
@@ -0,0 +1,33 @@
1
+ import styled, { keyframes } from "styled-components";
2
+
3
+ const shimmer = keyframes`
4
+ 0% {
5
+ background-position: 200% center;
6
+ }
7
+ 100% {
8
+ background-position: -200% center;
9
+ }
10
+ `;
11
+
12
+ export const TextShimmerWrapper = styled.span<{
13
+ "data-duration": number;
14
+ "data-spread": number;
15
+ "data-shimmer-color": string;
16
+ "data-base-color": string;
17
+ }>`
18
+ background: linear-gradient(
19
+ 90deg,
20
+ ${({ "data-base-color": baseColor }) => baseColor} 0%,
21
+ ${({ "data-shimmer-color": shimmerColor }) => shimmerColor} 40%,
22
+ ${({ "data-base-color": baseColor }) => baseColor} 60%,
23
+ ${({ "data-base-color": baseColor }) => baseColor} 100%
24
+ );
25
+ background-size: ${({ "data-spread": spread }) => spread}% auto;
26
+ background-clip: text;
27
+ -webkit-background-clip: text;
28
+ -webkit-text-fill-color: transparent;
29
+ color: transparent;
30
+ animation: ${shimmer} ${({ "data-duration": duration }) => duration}s linear
31
+ infinite;
32
+ display: inline-block;
33
+ `;