@12-apps/ui 1.4.0 → 1.5.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 (66) hide show
  1. package/eslint.config.js +12 -0
  2. package/package.json +5 -4
  3. package/src/components/data-display/AlertDialog/AlertDialog.test.stories.tsx +16 -6
  4. package/src/components/data-display/Banner/Banner.test.stories.tsx +26 -18
  5. package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
  6. package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
  7. package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
  8. package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
  9. package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
  10. package/src/components/data-display/Carousel/Carousel.test.stories.tsx +15 -5
  11. package/src/components/data-display/Carousel/Carousel.tsx +84 -732
  12. package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
  13. package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
  14. package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
  15. package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
  16. package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
  17. package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
  18. package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
  19. package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
  20. package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
  21. package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
  22. package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
  23. package/src/components/data-display/Sheet/Sheet.test.stories.tsx +25 -21
  24. package/src/components/data-display/Sheet/Sheet.tsx +125 -948
  25. package/src/components/feedback/Dialog/Dialog.test.stories.tsx +37 -28
  26. package/src/components/feedback/Modal/Modal.styles.ts +126 -0
  27. package/src/components/feedback/Modal/Modal.test.stories.tsx +5 -5
  28. package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
  29. package/src/components/feedback/Modal/Modal.tsx +37 -242
  30. package/src/components/feedback/StackedModal/StackedModal.test.stories.tsx +9 -11
  31. package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
  32. package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
  33. package/src/components/form/InputOTP/InputOTP.tsx +74 -221
  34. package/src/components/form/Label/Label.parts.tsx +75 -0
  35. package/src/components/form/Label/Label.styles.ts +206 -0
  36. package/src/components/form/Label/Label.test.stories.tsx +20 -14
  37. package/src/components/form/Label/Label.tsx +73 -325
  38. package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
  39. package/src/components/form/Menubar/Menubar.item.tsx +121 -0
  40. package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
  41. package/src/components/form/Menubar/Menubar.styles.ts +110 -0
  42. package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
  43. package/src/components/form/Menubar/Menubar.tsx +64 -347
  44. package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
  45. package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
  46. package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
  47. package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
  48. package/src/components/form/Slider/Slider.animations.ts +40 -0
  49. package/src/components/form/Slider/Slider.styles.ts +292 -0
  50. package/src/components/form/Slider/Slider.tsx +106 -357
  51. package/src/components/form/Switch/Switch.animations.ts +62 -0
  52. package/src/components/form/Switch/Switch.parts.tsx +213 -0
  53. package/src/components/form/Switch/Switch.styles.ts +355 -0
  54. package/src/components/form/Switch/Switch.tsx +87 -566
  55. package/src/components/form/ToggleGroup/ToggleGroup.test.stories.tsx +26 -29
  56. package/src/components/layout/Resizable/Resizable.test.stories.tsx +11 -7
  57. package/src/components/layout/Spacer/Spacer.test.stories.tsx +11 -9
  58. package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
  59. package/src/components/typography/Heading/Heading.styles.ts +101 -0
  60. package/src/components/typography/Heading/Heading.tsx +48 -164
  61. package/src/components/typography/Text/Text.test.stories.tsx +27 -30
  62. package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
  63. package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
  64. package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
  65. package/src/utils/index.ts +1 -0
  66. package/src/utils/withDefaults.ts +20 -0
package/eslint.config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
2
2
  import tsParser from '@typescript-eslint/parser';
3
3
  import storybook from "eslint-plugin-storybook";
4
+ import testFlakiness from 'eslint-plugin-test-flakiness';
4
5
  import globals from 'globals';
5
6
 
6
7
  import { config } from "@12-apps/eslint-config/base";
@@ -23,6 +24,17 @@ export default [
23
24
  },
24
25
  ...config,
25
26
  ...storybook.configs["flat/recommended"],
27
+
28
+ // The flakiness gate is a separate lane (eslint.flakiness.config.mjs), so its
29
+ // rules are not enabled here. Test files still carry inline disables for the
30
+ // few findings that are deliberate, and without the plugin registered those
31
+ // directives fail to resolve — "definition for rule ... was not found" — which
32
+ // this everyday lint run counts as a warning under --max-warnings 0.
33
+ // Registering the plugin with no rules turned on makes them resolve.
34
+ {
35
+ plugins: { 'test-flakiness': testFlakiness },
36
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
37
+ },
26
38
  // Override parser for all TypeScript files after storybook config
27
39
  {
28
40
  files: ['**/*.ts', '**/*.tsx'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -194,7 +194,7 @@
194
194
  "react-dom": "^18.0.0 || ^19.0.0"
195
195
  },
196
196
  "dependencies": {
197
- "@12-apps/forms-core": "^1.3.0",
197
+ "@12-apps/forms-core": "^1.4.0",
198
198
  "@emotion/react": "^11.14.0",
199
199
  "@emotion/styled": "^11.14.0",
200
200
  "@monaco-editor/react": "^4.7.0",
@@ -209,8 +209,8 @@
209
209
  "recharts": "^3.5.1"
210
210
  },
211
211
  "devDependencies": {
212
- "@12-apps/eslint-config": "^1.4.0",
213
- "@12-apps/typescript-config": "^1.4.0",
212
+ "@12-apps/eslint-config": "^1.5.0",
213
+ "@12-apps/typescript-config": "^1.5.0",
214
214
  "@playwright/test": "^1.55.0",
215
215
  "@storybook/addon-docs": "^9.1.10",
216
216
  "@storybook/addon-links": "^9.1.10",
@@ -226,6 +226,7 @@
226
226
  "concurrently": "^9.2.1",
227
227
  "eslint-import-resolver-typescript": "^4.4.4",
228
228
  "eslint-plugin-storybook": "9.1.10",
229
+ "eslint-plugin-test-flakiness": "^1.4.0",
229
230
  "glob": "^13.0.0",
230
231
  "globals": "^16.5.0",
231
232
  "http-server": "^14.1.1",
@@ -288,13 +288,15 @@ export const FocusManagement: Story = {
288
288
  await step('Tab through focusable elements', async () => {
289
289
  // Tab cycles through dialog elements
290
290
  await userEvent.tab();
291
- expect(document.activeElement?.getAttribute('aria-label')).toBe('close');
291
+ await waitFor(() =>
292
+ expect(document.activeElement?.getAttribute('aria-label')).toBe('close'),
293
+ );
292
294
 
293
295
  await userEvent.tab();
294
- expect(document.activeElement?.textContent).toContain('Cancel');
296
+ await waitFor(() => expect(document.activeElement?.textContent).toContain('Cancel'));
295
297
 
296
298
  await userEvent.tab();
297
- expect(document.activeElement?.textContent).toContain('Confirm');
299
+ await waitFor(() => expect(document.activeElement?.textContent).toContain('Confirm'));
298
300
  });
299
301
 
300
302
  await step('Close dialog and verify focus return', async () => {
@@ -519,6 +521,10 @@ const PerformanceComponent = () => {
519
521
  const startTime = window.performance.now();
520
522
  setDialogCount((prev) => prev + 1);
521
523
  setCurrentDialog(dialogCount + 1);
524
+ // This is the demo measuring itself for display, not an assertion — the
525
+ // story renders the number rather than asserting on it, so nothing here
526
+ // can fail on a slow machine.
527
+ // eslint-disable-next-line test-flakiness/no-animation-wait -- demo self-measurement, not an assertion
522
528
  window.requestAnimationFrame(() => {
523
529
  const endTime = window.performance.now();
524
530
  setRenderTime(endTime - startTime);
@@ -670,9 +676,13 @@ const IntegrationComponent = () => {
670
676
 
671
677
  const handleDelete = async () => {
672
678
  setIsDeleting(true);
673
- // Simulate API call
679
+ // The delay is the point of this story — it shows the pending state — so it
680
+ // is the demo's own behaviour rather than a test waiting for something.
681
+ // eslint-disable-next-line test-flakiness/no-hard-coded-timeout -- simulated latency is what this story demonstrates
674
682
  await new Promise((resolve) => window.setTimeout(resolve, 1000));
675
- setConfirmations((prev) => [...prev, `Deleted at ${new Date().toLocaleTimeString()}`]);
683
+ // Numbered rather than timestamped: the entry only needs to be distinct, and
684
+ // a clock reading makes the rendered text different on every run.
685
+ setConfirmations((prev) => [...prev, `Deleted item ${prev.length + 1}`]);
676
686
  setIsDeleting(false);
677
687
  };
678
688
 
@@ -746,7 +756,7 @@ export const Integration: Story = {
746
756
 
747
757
  await step('Verify action was logged', async () => {
748
758
  await waitFor(() => {
749
- expect(canvas.getByText(/Deleted at/)).toBeInTheDocument();
759
+ expect(canvas.getByText(/Deleted item/)).toBeInTheDocument();
750
760
  });
751
761
  });
752
762
  },
@@ -116,30 +116,34 @@ export const KeyboardNavigation: Story = {
116
116
 
117
117
  // Focus the banner
118
118
  const banner = canvas.getByRole('status');
119
- banner.focus();
120
- expect(banner).toHaveFocus();
119
+ await userEvent.click(banner);
120
+ await waitFor(() => expect(banner).toHaveFocus());
121
121
 
122
122
  // Tab to first action button
123
123
  await userEvent.tab();
124
124
  const action1 = canvas.getByRole('button', { name: 'Action 1' });
125
- expect(action1).toHaveFocus();
125
+ await waitFor(() => expect(action1).toHaveFocus());
126
126
 
127
127
  // Tab to second action button
128
128
  await userEvent.tab();
129
129
  const action2 = canvas.getByRole('button', { name: 'Action 2' });
130
- expect(action2).toHaveFocus();
130
+ await waitFor(() => expect(action2).toHaveFocus());
131
131
 
132
132
  // Tab to dismiss button
133
133
  await userEvent.tab();
134
134
  const dismissButton = canvas.getByRole('button', { name: /dismiss banner/i });
135
- expect(dismissButton).toHaveFocus();
135
+ await waitFor(() => expect(dismissButton).toHaveFocus());
136
136
 
137
- // Test Enter key on action button
137
+ // Test Enter key on action button. Focused directly rather than clicked:
138
+ // a click would fire the very handler the next line counts.
139
+ // eslint-disable-next-line test-flakiness/no-focus-check -- clicking would fire the handler under assertion
138
140
  action1.focus();
139
141
  await userEvent.keyboard('{Enter}');
140
142
  expect(args.actions![0].onClick).toHaveBeenCalledTimes(1);
141
143
 
142
- // Test Space key on dismiss button
144
+ // Test Space key on dismiss button. Focused rather than clicked for the
145
+ // same reason: a click is itself a dismiss.
146
+ // eslint-disable-next-line test-flakiness/no-focus-check, test-flakiness/await-async-events -- clicking would perform the action under test
143
147
  dismissButton.focus();
144
148
  await userEvent.keyboard(' ');
145
149
 
@@ -198,17 +202,17 @@ export const FocusManagement: Story = {
198
202
  expect(banner).toHaveAttribute('tabIndex', '0');
199
203
 
200
204
  // Test focus visibility
201
- banner.focus();
202
- expect(banner).toHaveFocus();
205
+ await userEvent.click(banner);
206
+ await waitFor(() => expect(banner).toHaveFocus());
203
207
 
204
208
  // Test that focus moves properly through interactive elements
205
209
  await userEvent.tab();
206
210
  const actionButton = canvas.getByRole('button', { name: 'Focus Test' });
207
- expect(actionButton).toHaveFocus();
211
+ await waitFor(() => expect(actionButton).toHaveFocus());
208
212
 
209
213
  await userEvent.tab();
210
214
  const dismissButton = canvas.getByRole('button', { name: /dismiss banner/i });
211
- expect(dismissButton).toHaveFocus();
215
+ await waitFor(() => expect(dismissButton).toHaveFocus());
212
216
  },
213
217
  };
214
218
 
@@ -365,14 +369,18 @@ export const Performance: Story = {
365
369
  const allDismissButtons = canvas.getAllByRole('button', { name: /dismiss banner/i });
366
370
 
367
371
  // Click first 3 dismiss buttons
368
- for (let i = 0; i < Math.min(3, allDismissButtons.length); i++) {
372
+ const dismissCount = Math.min(3, allDismissButtons.length);
373
+ for (let i = 0; i < dismissCount; i++) {
369
374
  await userEvent.click(allDismissButtons[i]);
370
- await waitFor(
371
- () => {
372
- // Wait a bit for dismiss animation
373
- },
374
- { timeout: 100 },
375
- );
375
+ // The old wait here had an empty body, so it was a fixed delay with a
376
+ // waitFor's name on it. Waiting for the banner count to actually drop is
377
+ // both the real signal and the assertion.
378
+ const remaining = allDismissButtons.length - (i + 1);
379
+ await waitFor(() => {
380
+ expect(canvas.queryAllByRole('button', { name: /dismiss banner/i })).toHaveLength(
381
+ remaining,
382
+ );
383
+ });
376
384
  }
377
385
  },
378
386
  };
@@ -0,0 +1,83 @@
1
+ import React from 'react';
2
+
3
+ import { CarouselArrows, CarouselIndicators, CarouselThumbnails } from './Carousel.parts';
4
+ import type {
5
+ CarouselArrowsProps,
6
+ CarouselIndicatorsProps,
7
+ CarouselItem,
8
+ CarouselThumbnailsProps,
9
+ } from './Carousel.types';
10
+
11
+ type IndicatorPosition = NonNullable<CarouselIndicatorsProps['position']>;
12
+ type Color = NonNullable<CarouselIndicatorsProps['color']>;
13
+
14
+ export interface CarouselChromeProps {
15
+ items: CarouselItem[];
16
+ activeIndex: number;
17
+ loop: boolean;
18
+ disabled: boolean;
19
+ showArrows: boolean;
20
+ showIndicators: boolean;
21
+ showThumbnails: boolean;
22
+ arrowPosition: NonNullable<CarouselArrowsProps['position']>;
23
+ indicatorPosition: IndicatorPosition;
24
+ color: Color;
25
+ size: NonNullable<CarouselThumbnailsProps['size']>;
26
+ onPrev: () => void;
27
+ onNext: () => void;
28
+ onSelect: (index: number) => void;
29
+ }
30
+
31
+ /**
32
+ * Everything drawn over the slides: the arrows, the indicators and the thumbnail
33
+ * strip. Each is independently switchable, so they travel together rather than
34
+ * as three conditional blocks inside the carousel's own render.
35
+ */
36
+ export const CarouselChrome: React.FC<CarouselChromeProps> = ({
37
+ items,
38
+ activeIndex,
39
+ loop,
40
+ disabled,
41
+ showArrows,
42
+ showIndicators,
43
+ showThumbnails,
44
+ arrowPosition,
45
+ indicatorPosition,
46
+ color,
47
+ size,
48
+ onPrev,
49
+ onNext,
50
+ onSelect,
51
+ }) => (
52
+ <>
53
+ {showArrows && !disabled && (
54
+ <CarouselArrows
55
+ onPrev={onPrev}
56
+ onNext={onNext}
57
+ position={arrowPosition}
58
+ color={color}
59
+ disablePrev={!loop && activeIndex === 0}
60
+ disableNext={!loop && activeIndex === items.length - 1}
61
+ />
62
+ )}
63
+
64
+ {showIndicators && (
65
+ <CarouselIndicators
66
+ count={items.length}
67
+ activeIndex={activeIndex}
68
+ onSelect={onSelect}
69
+ position={indicatorPosition}
70
+ color={color}
71
+ />
72
+ )}
73
+
74
+ {showThumbnails && (
75
+ <CarouselThumbnails
76
+ items={items}
77
+ activeIndex={activeIndex}
78
+ onSelect={onSelect}
79
+ size={size}
80
+ />
81
+ )}
82
+ </>
83
+ );
@@ -0,0 +1,88 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ interface CarouselNavigationInput {
4
+ count: number;
5
+ loop: boolean;
6
+ autoPlay: boolean;
7
+ autoPlayInterval: number;
8
+ pauseOnHover: boolean;
9
+ disabled: boolean;
10
+ onChange?: (index: number) => void;
11
+ }
12
+
13
+ /**
14
+ * Steps `current` by `step`, wrapping when `loop` allows it and stopping at the
15
+ * end when it does not. Returns the same index it was given when there is
16
+ * nowhere to go, which is what lets the caller skip a no-op `onChange`.
17
+ */
18
+ const stepIndex = (current: number, step: number, count: number, loop: boolean) => {
19
+ const next = current + step;
20
+
21
+ if (next >= count) return loop ? 0 : current;
22
+ if (next < 0) return loop ? count - 1 : 0;
23
+ return next;
24
+ };
25
+
26
+ /**
27
+ * Which slide is showing and everything that changes it: the arrows, an
28
+ * indicator click, and the autoplay timer.
29
+ *
30
+ * Hover is tracked here rather than in the component because its only job is to
31
+ * pause that timer.
32
+ */
33
+ export const useCarouselNavigation = ({
34
+ count,
35
+ loop,
36
+ autoPlay,
37
+ autoPlayInterval,
38
+ pauseOnHover,
39
+ disabled,
40
+ onChange,
41
+ }: CarouselNavigationInput) => {
42
+ const [activeIndex, setActiveIndex] = useState(0);
43
+ const [isHovered, setIsHovered] = useState(false);
44
+ const intervalRef = useRef<number | null>(null);
45
+
46
+ const goBy = useCallback(
47
+ (step: number) => {
48
+ setActiveIndex((prev) => {
49
+ const next = stepIndex(prev, step, count, loop);
50
+ if (next !== prev) {
51
+ onChange?.(next);
52
+ }
53
+ return next;
54
+ });
55
+ },
56
+ [count, loop, onChange],
57
+ );
58
+
59
+ const handleNext = useCallback(() => goBy(1), [goBy]);
60
+ const handlePrev = useCallback(() => goBy(-1), [goBy]);
61
+
62
+ const handleSelect = useCallback(
63
+ (index: number) => {
64
+ setActiveIndex(index);
65
+ onChange?.(index);
66
+ },
67
+ [onChange],
68
+ );
69
+
70
+ useEffect(() => {
71
+ if (!autoPlay || isHovered || disabled) return;
72
+
73
+ intervalRef.current = window.setInterval(handleNext, autoPlayInterval);
74
+
75
+ return () => {
76
+ if (intervalRef.current) {
77
+ window.clearInterval(intervalRef.current);
78
+ }
79
+ };
80
+ }, [autoPlay, autoPlayInterval, isHovered, disabled, handleNext]);
81
+
82
+ const hoverProps = {
83
+ onMouseEnter: () => pauseOnHover && setIsHovered(true),
84
+ onMouseLeave: () => pauseOnHover && setIsHovered(false),
85
+ };
86
+
87
+ return { activeIndex, handleNext, handlePrev, handleSelect, hoverProps };
88
+ };