@agregio-solutions/design-system 1.99.0 → 1.101.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.
@@ -3,6 +3,7 @@ import { ButtonProps } from 'react-aria-components';
3
3
  import { HTMLAttributeAnchorTarget } from 'react';
4
4
  import { Props as TooltipProps } from '../Tooltip/Tooltip';
5
5
  import { Props as BadgeProps } from '../Badge/Badge';
6
+ export type ButtonSize = "small" | "medium" | "large";
6
7
  export type Props = Omit<ButtonProps, "onPress" | "isDisabled" | "children"> & {
7
8
  /**
8
9
  * Handler that is called when the press is released over the target.
@@ -17,7 +18,7 @@ export type Props = Omit<ButtonProps, "onPress" | "isDisabled" | "children"> & {
17
18
  * Specifies the size of the button.
18
19
  * @default "medium".
19
20
  */
20
- size?: "small" | "medium" | "large";
21
+ size?: ButtonSize;
21
22
  /**
22
23
  * Specifies the button colour.
23
24
  * @default "action".
@@ -63,6 +64,14 @@ export type Props = Omit<ButtonProps, "onPress" | "isDisabled" | "children"> & {
63
64
  * Props to pass to the tooltip. Only used if `tooltip` is provided.
64
65
  */
65
66
  tooltipProps?: Omit<TooltipProps, "label" | "children">;
67
+ /**
68
+ * Require the user to keep the button pressed for this duration (in milliseconds)
69
+ * before `onClick` is called. While pressed, text and icons are hidden (the button
70
+ * keeps its size) and a circular progress fills up. Releasing early resets
71
+ * everything without calling `onClick`.
72
+ * Omit it (or pass 0) to get a regular click button. Not supported with `href`.
73
+ */
74
+ holdToSubmitDuration?: number;
66
75
  /**
67
76
  * Add a badge to the button on the top right corner.
68
77
  * If not provided, the badge will not be displayed.
@@ -83,7 +92,7 @@ declare const Button: import('react').ForwardRefExoticComponent<Omit<ButtonProps
83
92
  * Specifies the size of the button.
84
93
  * @default "medium".
85
94
  */
86
- size?: "small" | "medium" | "large";
95
+ size?: ButtonSize;
87
96
  /**
88
97
  * Specifies the button colour.
89
98
  * @default "action".
@@ -129,6 +138,14 @@ declare const Button: import('react').ForwardRefExoticComponent<Omit<ButtonProps
129
138
  * Props to pass to the tooltip. Only used if `tooltip` is provided.
130
139
  */
131
140
  tooltipProps?: Omit<TooltipProps, "label" | "children">;
141
+ /**
142
+ * Require the user to keep the button pressed for this duration (in milliseconds)
143
+ * before `onClick` is called. While pressed, text and icons are hidden (the button
144
+ * keeps its size) and a circular progress fills up. Releasing early resets
145
+ * everything without calling `onClick`.
146
+ * Omit it (or pass 0) to get a regular click button. Not supported with `href`.
147
+ */
148
+ holdToSubmitDuration?: number;
132
149
  /**
133
150
  * Add a badge to the button on the top right corner.
134
151
  * If not provided, the badge will not be displayed.
@@ -0,0 +1,22 @@
1
+ import { ButtonSize } from '../../Button';
2
+ export interface Props {
3
+ /**
4
+ * Size of the button it fills, so the circle scales with it.
5
+ */
6
+ size: ButtonSize;
7
+ /**
8
+ * How long the fill animation lasts, in milliseconds.
9
+ */
10
+ duration: number;
11
+ }
12
+ /**
13
+ * Circular progress shown while the user holds a button down.
14
+ * The whole animation is driven by CSS: mounting the SVG (re)starts it, so
15
+ * cancelling a hold needs no extra work, and holding triggers no re-render.
16
+ *
17
+ * It carries a `progressbar` role so it exists in the accessibility tree while
18
+ * the button label is hidden. Its value stays at 0 on purpose: announcing every
19
+ * percent would mean re-rendering on each frame, and the label already tells
20
+ * the user what to do.
21
+ */
22
+ export default function HoldProgress({ size, duration }: Props): import("react").JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { IconName } from '../../../Icon/Icon';
2
+ import { ButtonSize } from '../../Button';
3
+ export interface Props {
4
+ iconLeft?: IconName;
5
+ isLoading?: boolean;
6
+ size?: ButtonSize;
7
+ }
8
+ /**
9
+ * Leading slot of a button: the loader takes over the icon while loading.
10
+ */
11
+ export default function IconLeftElement({ iconLeft, isLoading, size }: Props): import("react").JSX.Element | null;
@@ -0,0 +1,12 @@
1
+ import { IconName } from '../../../Icon/Icon';
2
+ import { ButtonSize } from '../../Button';
3
+ export interface Props {
4
+ iconRight?: IconName;
5
+ text?: string | number;
6
+ isLoading?: boolean;
7
+ size?: ButtonSize;
8
+ }
9
+ /**
10
+ * Trailing slot of a button.
11
+ */
12
+ export default function IconRightElement({ iconRight, text, isLoading, size, }: Props): import("react").JSX.Element | null;
@@ -119,6 +119,20 @@ export const Loading: StoryObj<typeof Button> = {
119
119
  },
120
120
  };
121
121
 
122
+ export const HoldToSubmit: StoryObj<typeof Button> = {
123
+ args: {
124
+ ...Playground.args,
125
+ text: "Supprimer",
126
+ nature: "negative",
127
+ holdToSubmitDuration: 1000,
128
+ },
129
+ play: async ({ canvasElement, args }) => {
130
+ const canvas = within(canvasElement);
131
+ await expect(canvas.getByText("Supprimer")).toBeVisible();
132
+ await expect(args.onClick).not.toHaveBeenCalled();
133
+ },
134
+ };
135
+
122
136
  export const Informative: StoryObj<typeof Button> = {
123
137
  args: {
124
138
  ...Playground.args,
@@ -332,12 +346,13 @@ Here are some more advanced stories with more testing coverage and examples that
332
346
 
333
347
  ```tsx
334
348
  import { Meta, StoryObj } from "@storybook/react-vite";
335
- import { within, expect } from "storybook/test";
349
+ import { within, expect, userEvent, waitFor, fireEvent } from "storybook/test";
336
350
 
337
351
  import Button from "../Button";
338
352
  import ErrorBoundary from "@packages/internal-components/ErrorBoundary/ErrorBoundary";
339
353
  import { ICON_NAMES_ARRAY } from "@components/Icon/Icon";
340
354
  import { Playground } from "../Button.stories";
355
+ import { someTime } from "@internal/test-utils-storybook/test-utils-storybook";
341
356
 
342
357
  const meta: Meta<typeof Button> = {
343
358
  component: Button,
@@ -396,6 +411,89 @@ export const LinkWithTarget: StoryObj<typeof Button> = {
396
411
  await expect(linkElement).toHaveAttribute("target", "_blank");
397
412
  },
398
413
  };
414
+
415
+ const HOLD_DURATION = 300;
416
+
417
+ // `userEvent.pointer` does not drive React Aria's press events here, so we
418
+ // dispatch the pointer events ourselves to press without releasing.
419
+ const POINTER_EVENT_INIT = {
420
+ pointerId: 1,
421
+ pointerType: "mouse",
422
+ button: 0,
423
+ isPrimary: true,
424
+ // width/height/pressure/detail must look like a real mouse, otherwise React
425
+ // Aria treats the event as a virtual pointer and waits for a click instead
426
+ width: 1,
427
+ height: 1,
428
+ pressure: 0.5,
429
+ detail: 1,
430
+ };
431
+
432
+ const pressWithoutReleasing = (element: HTMLElement) =>
433
+ fireEvent.pointerDown(element, { ...POINTER_EVENT_INIT, buttons: 1 });
434
+
435
+ const release = (element: HTMLElement) =>
436
+ fireEvent.pointerUp(element, { ...POINTER_EVENT_INIT, buttons: 0 });
437
+
438
+ const holdArgs = {
439
+ ...Playground.args,
440
+ holdToSubmitDuration: HOLD_DURATION,
441
+ };
442
+
443
+ export const HoldShortClickDoesNothing: StoryObj<typeof Button> = {
444
+ args: holdArgs,
445
+ play: async ({ canvasElement, args }) => {
446
+ const canvas = within(canvasElement);
447
+ const user = userEvent.setup();
448
+ await user.click(canvas.getByRole("button"));
449
+ await someTime(HOLD_DURATION * 2);
450
+ await expect(args.onClick).not.toHaveBeenCalled();
451
+ },
452
+ };
453
+
454
+ export const HoldReleasedTooEarly: StoryObj<typeof Button> = {
455
+ args: holdArgs,
456
+ play: async ({ canvasElement, args }) => {
457
+ const canvas = within(canvasElement);
458
+ const button = canvas.getByRole("button");
459
+
460
+ await pressWithoutReleasing(button);
461
+ await someTime(HOLD_DURATION / 3);
462
+ await expect(canvas.getByText("[Insert name]")).not.toBeVisible();
463
+ // The progress must be in the accessibility tree while the label is hidden
464
+ await expect(
465
+ canvas.getByRole("progressbar", { name: "Keep holding to confirm" }),
466
+ ).toBeInTheDocument();
467
+
468
+ await release(button);
469
+ await someTime(HOLD_DURATION * 2);
470
+ await expect(args.onClick).not.toHaveBeenCalled();
471
+ await expect(canvas.getByText("[Insert name]")).toBeVisible();
472
+ await expect(canvas.queryByRole("progressbar")).not.toBeInTheDocument();
473
+ },
474
+ };
475
+
476
+ export const HoldCompleted: StoryObj<typeof Button> = {
477
+ args: holdArgs,
478
+ play: async ({ canvasElement, args }) => {
479
+ const canvas = within(canvasElement);
480
+ await pressWithoutReleasing(canvas.getByRole("button"));
481
+ await waitFor(() => expect(args.onClick).toHaveBeenCalledTimes(1));
482
+ },
483
+ };
484
+
485
+ export const HoldByKeyboard: StoryObj<typeof Button> = {
486
+ args: holdArgs,
487
+ play: async ({ canvasElement, args }) => {
488
+ const canvas = within(canvasElement);
489
+ const user = userEvent.setup();
490
+ await user.tab();
491
+ await expect(canvas.getByRole("button")).toHaveFocus();
492
+ // Hold Enter down without releasing it
493
+ await user.keyboard("{Enter>}");
494
+ await waitFor(() => expect(args.onClick).toHaveBeenCalledTimes(1));
495
+ },
496
+ };
399
497
  ```
400
498
 
401
499
  ## Developer notes
@@ -422,4 +520,13 @@ import * as Button from "./Button.stories";
422
520
  ## Button props
423
521
 
424
522
  <Controls of={Button.Playground} />
523
+
524
+ ## Hold to submit
525
+
526
+ Give `holdToSubmitDuration` a duration in milliseconds to require the user to
527
+ keep the button pressed before `onClick` fires. Text and icons are hidden while
528
+ holding — the button keeps its size — and a circular progress fills up.
529
+ Releasing early resets everything without calling `onClick`.
530
+
531
+ <Canvas of={Button.HoldToSubmit} />
425
532
  ```
@@ -84,6 +84,12 @@ export type Props = Pick<LabelProps, "label" | "labelIconRight" | "labelIconRigh
84
84
  * When true, increases the dropdown max-height from 200px to 400px.
85
85
  */
86
86
  tallDropdown?: boolean;
87
+ /**
88
+ * An element rendered inside the field, on the left of the dropdown trigger.
89
+ * Typically a `Badge` displaying the number of selected items in `selectionMode="multiple"`.
90
+ * It lays out in the flow, so the input shrinks to make room for it whatever its width.
91
+ */
92
+ triggerContentRight?: React.ReactNode;
87
93
  };
88
94
  declare const Combobox: React.ForwardRefExoticComponent<Pick<LabelProps, "label" | "required" | "labelIconRight" | "labelIconRightTooltip"> & Pick<FormGroupWrapperProps, "orientation" | "description" | "helperText" | "helperTextIcon" | "errorHelperText" | "errorHelperTextIcon" | "successHelperText" | "successHelperTextIcon" | "warningHelperText" | "warningHelperTextIcon"> & {
89
95
  /**
@@ -166,5 +172,11 @@ declare const Combobox: React.ForwardRefExoticComponent<Pick<LabelProps, "label"
166
172
  * When true, increases the dropdown max-height from 200px to 400px.
167
173
  */
168
174
  tallDropdown?: boolean;
175
+ /**
176
+ * An element rendered inside the field, on the left of the dropdown trigger.
177
+ * Typically a `Badge` displaying the number of selected items in `selectionMode="multiple"`.
178
+ * It lays out in the flow, so the input shrinks to make room for it whatever its width.
179
+ */
180
+ triggerContentRight?: React.ReactNode;
169
181
  } & React.RefAttributes<HTMLInputElement>>;
170
182
  export default Combobox;
@@ -56,7 +56,7 @@ export const Playground: StoryObj<typeof Combobox> = {
56
56
  { text: "Option disabled", id: "disabled" },
57
57
  ],
58
58
  disabledIds: ["disabled"],
59
- placeholder: "Please select an option",
59
+ placeholder: "Select an option",
60
60
  labelIconRight: "help_outline",
61
61
  labelIconRightTooltip: "Additional information",
62
62
  required: true,
@@ -184,6 +184,7 @@ Here are some more advanced stories with more testing coverage and examples that
184
184
  ```tsx
185
185
  import { Meta, StoryObj } from "@storybook/react-vite";
186
186
 
187
+ import Badge from "@packages/components/Badge/Badge";
187
188
  import Combobox from "../Combobox";
188
189
  import { Playground } from "../Combobox.stories";
189
190
  import { userEvent, within, screen, expect } from "storybook/test";
@@ -588,6 +589,13 @@ export const MultipleSelectionExample: StoryObj<typeof Combobox> = {
588
589
  id: perimeter.id,
589
590
  isSelected: selectedPerimetersIds.includes(perimeter.id),
590
591
  }))}
592
+ // Display the number of selected items inside the field
593
+ triggerContentRight={
594
+ <Badge
595
+ value={selectedPerimetersIds.length}
596
+ nature="informative"
597
+ />
598
+ }
591
599
  />
592
600
 
593
601
  <div>
@@ -603,6 +611,75 @@ export const MultipleSelectionExample: StoryObj<typeof Combobox> = {
603
611
  };
604
612
  return <ParentComponent />;
605
613
  },
614
+ play: async ({ canvasElement }) => {
615
+ const canvas = within(canvasElement);
616
+ const user = userEvent.setup({ delay: 50 });
617
+ const clickOption = async (text: string) =>
618
+ user.click(await screen.findByText(text, { selector: "span" }));
619
+
620
+ // The badge renders nothing while no item is selected
621
+ await expectNotPresent(() => canvas.queryByText("0"));
622
+
623
+ // The dropdown stays open across selections, since the selected key never changes
624
+ await user.click(canvas.getByRole("button"));
625
+
626
+ await clickOption("Perimeter 1");
627
+ await expect(canvas.getByText("1")).toBeInTheDocument();
628
+
629
+ await clickOption("Perimeter 2");
630
+ await expect(canvas.getByText("2")).toBeInTheDocument();
631
+
632
+ // Unselecting brings the count back down
633
+ await clickOption("Perimeter 1");
634
+ await expect(canvas.getByText("1")).toBeInTheDocument();
635
+ },
636
+ };
637
+
638
+ export const ShouldLayOutTheFieldShellAroundItsContent: StoryObj<
639
+ typeof Combobox
640
+ > = {
641
+ // Keeps the field away from the viewport edges, where the popover gets clamped
642
+ decorators: [
643
+ (Story) => (
644
+ <div style={{ padding: "var(--spacing-xl)" }}>
645
+ <Story />
646
+ </div>
647
+ ),
648
+ ],
649
+ args: {
650
+ ...Playground.args,
651
+ selectionMode: "multiple",
652
+ // A wide badge must not overlap the typed text: the input shrinks instead
653
+ triggerContentRight: <Badge value={9999} nature="informative" />,
654
+ },
655
+ play: async ({ canvasElement }) => {
656
+ const canvas = within(canvasElement);
657
+ const user = userEvent.setup({ delay: 50 });
658
+ const input = canvas.getByLabelText("[Insert label]");
659
+ const field = input.parentElement!;
660
+ const badge = canvas.getByText("9999");
661
+
662
+ // The border lives on the field shell, not on the input
663
+ await expect(getComputedStyle(field).outlineWidth).toBe("1px");
664
+ await expect(getComputedStyle(input).outlineStyle).toBe("none");
665
+
666
+ // The badge sits inside the field, past the end of the input
667
+ await expect(field).toContainElement(badge);
668
+ await expect(badge.getBoundingClientRect().left).toBeGreaterThanOrEqual(
669
+ input.getBoundingClientRect().right,
670
+ );
671
+ await expect(field.getBoundingClientRect().right).toBeGreaterThan(
672
+ badge.getBoundingClientRect().right,
673
+ );
674
+
675
+ // The dropdown still spans the whole field, and stays aligned with it
676
+ await user.click(within(field).getByRole("button"));
677
+ const listbox = await screen.findByRole("listbox");
678
+ const fieldBox = field.getBoundingClientRect();
679
+ const listboxBox = listbox.getBoundingClientRect();
680
+ await expect(listboxBox.width).toBe(fieldBox.width);
681
+ await expect(Math.round(listboxBox.left)).toBe(Math.round(fieldBox.left));
682
+ },
606
683
  };
607
684
  ```
608
685
 
@@ -687,6 +764,10 @@ Here is an example of how to use the combobox with multiple selection.
687
764
  Please note that this is a temporary solution, the React Aria combobox does not support multiple selection yet.
688
765
  The implementation details may change in the future.
689
766
 
767
+ Because the selection is owned by the consumer, the combobox cannot count the selected items itself.
768
+ Use the `triggerContentRight` prop to display a `Badge` with the count inside the field, on the left of the dropdown trigger.
769
+ The element lays out in the flow, so the input shrinks to make room for it whatever its width, and it takes no room at all when the badge renders nothing.
770
+
690
771
  <Source of={ComboboxTests.MultipleSelectionExample} type="code" dark />
691
772
 
692
773
  ## How to test this component?
@@ -125,6 +125,7 @@ import { default as phone_2_full } from './components/phone_2_full';
125
125
  import { default as play } from './components/play';
126
126
  import { default as play_empty } from './components/play_empty';
127
127
  import { default as play_full } from './components/play_full';
128
+ import { default as power_off } from './components/power_off';
128
129
  import { default as profile } from './components/profile';
129
130
  import { default as profile_full } from './components/profile_full';
130
131
  import { default as progress } from './components/progress';
@@ -313,6 +314,7 @@ export declare const AVAILABLE_ICONS: {
313
314
  play_empty: typeof play_empty;
314
315
  play_full: typeof play_full;
315
316
  play: typeof play;
317
+ power_off: typeof power_off;
316
318
  profile_full: typeof profile_full;
317
319
  profile: typeof profile;
318
320
  progress: typeof progress;
@@ -0,0 +1,2 @@
1
+ import { IconBaseProps } from '../Icon';
2
+ export default function IconPowerOff(props: IconBaseProps): import("react").JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agregio-solutions/design-system",
3
- "version": "1.99.0",
3
+ "version": "1.101.0",
4
4
  "description": "React Component library and Storybook that is part of the Design System for Agregio Solutions",
5
5
  "type": "module",
6
6
  "module": "dist/design-system.js",