@hyphen/hyphen-components 3.0.0 → 4.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyphen/hyphen-components",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "@hyphen"
@@ -4,7 +4,7 @@ import { Box } from '../Box/Box';
4
4
 
5
5
  import * as Stories from './Button.stories';
6
6
 
7
- <Meta of={Stories} />
7
+ <Meta of={Stories} title="Components/Button" component={Button} />
8
8
 
9
9
  # Button
10
10
 
@@ -18,6 +18,12 @@ Actions almost always occur on the same page.
18
18
 
19
19
  <ArgTypes of={Button} />
20
20
 
21
+ ## As Child
22
+
23
+ Apply the `asChild` prop to apply the button styles to a child element.
24
+
25
+ <Canvas of={Stories.AsChild} />
26
+
21
27
  ## Variants
22
28
 
23
29
  The `variant` prop determines which color visual weight to render.
@@ -70,9 +76,3 @@ The interface should make it clear why the button is disabled and what needs to
70
76
 
71
77
  <Canvas of={Stories.Disabled} />
72
78
 
73
- ## As an Anchor
74
-
75
- You can render an anchor tag with the style of a button by using the `as` prop. You
76
- can render buttons as one of `button`, `a`, and `input`.
77
-
78
- <Canvas of={Stories.Anchor} />
@@ -58,6 +58,7 @@
58
58
  --button-size-lg-border-radius,
59
59
  var(--INTERNAL_form-control-size-lg-border-radius)
60
60
  );
61
+ gap: var(--size-spacing-lg);
61
62
  padding: var(
62
63
  --button-size-lg-padding-vertical,
63
64
  var(--INTERNAL_form-control-size-lg-padding)
@@ -89,6 +90,7 @@
89
90
  line-height: 1;
90
91
  font-family: var(--assets-font-family-body);
91
92
  display: inline-flex;
93
+ gap: var(--size-spacing-sm);
92
94
  align-items: center;
93
95
  justify-content: center;
94
96
  position: relative;
@@ -103,6 +105,7 @@
103
105
  text-decoration: none;
104
106
  }
105
107
 
108
+ &[aria-disabled='true'],
106
109
  &:disabled {
107
110
  opacity: 0.45;
108
111
  cursor: not-allowed;
@@ -23,6 +23,14 @@ export const Default = () => (
23
23
  <Button onClick={() => alert('clicked')}>Button</Button>
24
24
  );
25
25
 
26
+ export const AsChild = () => (
27
+ <Button asChild>
28
+ <a href="https://ux.hyphen.ai" target="_blank" rel="noreferrer">
29
+ I'm an anchor
30
+ </a>
31
+ </Button>
32
+ );
33
+
26
34
  export const Variants = () => (
27
35
  <Box gap="md" style={{ backgroundColor: 'var(--background-primary)' }}>
28
36
  <Box gap="sm" direction="row" alignItems="flex-start">
@@ -66,7 +74,7 @@ export const FullWidth = () => (
66
74
  );
67
75
 
68
76
  export const Icons = () => (
69
- <Box direction="row" gap="xs" alignItems="flex-start">
77
+ <Box direction="row" gap="md" alignItems="flex-start">
70
78
  <Button variant="primary" iconPrefix="mail">
71
79
  Email
72
80
  </Button>
@@ -85,7 +93,7 @@ export const IconButton = () => (
85
93
  );
86
94
 
87
95
  export const Loading = () => (
88
- <Box direction="row" gap="sm">
96
+ <Box direction="row" gap="md">
89
97
  <Button isLoading>Primary Loading</Button>
90
98
  <Button variant="secondary" isLoading>
91
99
  Secondary Loading
@@ -97,7 +105,7 @@ export const Loading = () => (
97
105
  );
98
106
 
99
107
  export const Disabled = () => (
100
- <Box direction="row" gap="sm">
108
+ <Box direction="row" gap="md">
101
109
  <Button variant="primary" isDisabled>
102
110
  Primary Disabled
103
111
  </Button>
@@ -111,7 +119,7 @@ export const Disabled = () => (
111
119
  );
112
120
 
113
121
  export const Shadow = () => (
114
- <Box direction="row" gap="sm">
122
+ <Box direction="row" gap="md">
115
123
  <Button variant="secondary" shadow="xs">
116
124
  xs shadow
117
125
  </Button>
@@ -123,11 +131,3 @@ export const Shadow = () => (
123
131
  </Button>
124
132
  </Box>
125
133
  );
126
-
127
- export const Anchor = () => (
128
- <Box direction="row" gap="sm">
129
- <Button as="a" href="https://ux.hyphen.ai" target="_blank">
130
- I'm an anchor tag
131
- </Button>
132
- </Box>
133
- );
@@ -1,43 +1,40 @@
1
+ import React from 'react';
1
2
  import { BUTTON_SIZES, BUTTON_VARIANTS } from './Button.constants';
2
3
  import { Button, ButtonVariant } from './Button';
3
4
  import { fireEvent, render, screen } from '@testing-library/react';
4
5
 
5
- import React from 'react';
6
-
7
6
  const renderButton = (props = {}) => render(<Button {...props} />);
8
7
  const getButton = (text: string): HTMLButtonElement =>
9
8
  screen.getByText(text).closest('button') as HTMLButtonElement;
10
- const getAnchor = (text: string): HTMLAnchorElement =>
11
- screen.getByText(text).closest('a') as HTMLAnchorElement;
12
9
 
13
10
  describe('Button', () => {
14
- describe('HTML Button Type', () => {
15
- test('is set to button by default', () => {
16
- renderButton({ children: 'Button' });
17
- const testBtn = screen.getByRole('button');
18
- expect(testBtn).toHaveAttribute('type', 'button');
19
- });
20
-
21
- test('is set to submit if specified', () => {
22
- renderButton({ type: 'submit', children: 'Submit Button' });
23
- const testBtn = screen.getByRole('button');
24
- expect(testBtn).toHaveAttribute('type', 'submit');
11
+ describe('Disabled states', () => {
12
+ test('supports controlled isLoading state', () => {
13
+ const { rerender } = renderButton({
14
+ isLoading: true,
15
+ children: 'Loading Button',
16
+ });
17
+ const button = getButton('Loading Button');
18
+ expect(button).toHaveAttribute('aria-disabled', 'true');
19
+ expect(button).toBeDisabled();
20
+
21
+ rerender(<Button isLoading={false} children="Loading Button" />);
22
+ expect(button).not.toHaveAttribute('aria-disabled');
23
+ expect(button).not.toBeDisabled();
25
24
  });
26
25
 
27
- test('is set to reset if specified', () => {
28
- renderButton({ type: 'reset', children: 'Reset Button' });
29
- const testBtn = screen.getByRole('button');
30
- expect(testBtn).toHaveAttribute('type', 'reset');
31
- });
32
-
33
- test('is not set if as prop is an anchor tag', () => {
34
- renderButton({
35
- as: 'a',
36
- href: 'https://www.hyphen.ai',
37
- children: 'link button',
26
+ test('supports controlled isDisabled state', () => {
27
+ const { rerender } = renderButton({
28
+ isDisabled: true,
29
+ children: 'Disabled Button',
38
30
  });
39
- const testBtn = screen.getByText('link button').parentElement;
40
- expect(testBtn).not.toHaveAttribute('type');
31
+ const button = getButton('Disabled Button');
32
+ expect(button).toHaveAttribute('aria-disabled', 'true');
33
+ expect(button).toBeDisabled();
34
+
35
+ rerender(<Button isDisabled={false} children="Disabled Button" />);
36
+ expect(button).not.toHaveAttribute('aria-disabled');
37
+ expect(button).not.toBeDisabled();
41
38
  });
42
39
  });
43
40
 
@@ -107,6 +104,18 @@ describe('Button', () => {
107
104
  expect(mockedHandleClick).toHaveBeenCalledTimes(1);
108
105
  });
109
106
 
107
+ test('does not fire onClick callback when disabled', () => {
108
+ const mockedHandleClick = jest.fn();
109
+ renderButton({
110
+ onClick: mockedHandleClick,
111
+ children: 'Click',
112
+ isDisabled: true,
113
+ });
114
+ const buttonElement: HTMLButtonElement = getButton('Click');
115
+ fireEvent.click(buttonElement);
116
+ expect(mockedHandleClick).toHaveBeenCalledTimes(0);
117
+ });
118
+
110
119
  test('does not fire function if onClick callback not provided', () => {
111
120
  const mockedHandleClick = jest.fn();
112
121
  renderButton({ children: 'Click' });
@@ -139,6 +148,18 @@ describe('Button', () => {
139
148
  expect(mockedHandleFocus).toHaveBeenCalledTimes(1);
140
149
  });
141
150
 
151
+ test('does not fire onFocus callback when disabled', () => {
152
+ const mockedHandleFocus = jest.fn();
153
+ renderButton({
154
+ onFocus: mockedHandleFocus,
155
+ children: 'Focus',
156
+ isDisabled: true,
157
+ });
158
+ const buttonElement = getButton('Focus');
159
+ fireEvent.focus(buttonElement);
160
+ expect(mockedHandleFocus).toHaveBeenCalledTimes(0);
161
+ });
162
+
142
163
  test('does not fire function if onFocus callback not provided', () => {
143
164
  const mockedHandleFocus = jest.fn();
144
165
  renderButton({ children: 'Focus' });
@@ -157,6 +178,18 @@ describe('Button', () => {
157
178
  expect(mockedHandleBlur).toHaveBeenCalledTimes(1);
158
179
  });
159
180
 
181
+ test('does not fire onBlur callback when disabled', () => {
182
+ const mockedHandleBlur = jest.fn();
183
+ renderButton({
184
+ onBlur: mockedHandleBlur,
185
+ children: 'Blur',
186
+ isDisabled: true,
187
+ });
188
+ const buttonElement = getButton('Blur');
189
+ fireEvent.blur(buttonElement);
190
+ expect(mockedHandleBlur).toHaveBeenCalledTimes(0);
191
+ });
192
+
160
193
  test('does not fire onBlur callback if not provided', () => {
161
194
  const mockedHandleBlur = jest.fn();
162
195
  renderButton({ children: 'Blur' });
@@ -305,104 +338,87 @@ describe('Button', () => {
305
338
  });
306
339
  });
307
340
  });
341
+ });
308
342
 
309
- describe('Anchor', () => {
310
- test('renders an anchor tag if as prop `a` is passed', () => {
311
- renderButton({
312
- as: 'a',
313
- href: 'http://hyphen.ai',
314
- children: 'hey there',
315
- });
316
- const buttonElement = screen.getByRole('link');
317
- expect(buttonElement).toBeInTheDocument();
318
- });
319
-
320
- test('does not have a button type attribute if as prop `a` is passed', () => {
321
- renderButton({
322
- as: 'a',
323
- href: 'http://hyphen.ai',
324
- children: 'hey there',
325
- });
326
- const buttonElement = screen.getByRole('link');
327
- expect(buttonElement).not.toHaveAttribute('type');
328
- });
343
+ describe('Role Attribute', () => {
344
+ test('applies role attribute', () => {
345
+ renderButton({ role: 'button', children: 'Button with Role' });
346
+ const buttonElement = getButton('Button with Role');
347
+ expect(buttonElement).toHaveAttribute('role', 'button');
348
+ });
349
+ });
350
+ describe('Aria Attributes', () => {
351
+ test('applies aria-label attribute', () => {
352
+ renderButton({ 'aria-label': 'Aria Label Button', children: 'Button' });
353
+ const buttonElement = getButton('Button');
354
+ expect(buttonElement).toHaveAttribute('aria-label', 'Aria Label Button');
355
+ });
329
356
 
330
- test('renders a target attribute if one is passed, the element is an anchor, and there is a href', () => {
331
- renderButton({
332
- as: 'a',
333
- href: 'http://hyphen.ai',
334
- target: '_blank',
335
- children: 'hey there',
336
- });
337
- const buttonElement = screen.getByRole('link');
338
- expect(buttonElement).toHaveAttribute('target', '_blank');
339
- });
357
+ test('applies aria-labelledby attribute', () => {
358
+ renderButton({ 'aria-labelledby': 'label-id', children: 'Button' });
359
+ const buttonElement = getButton('Button');
360
+ expect(buttonElement).toHaveAttribute('aria-labelledby', 'label-id');
361
+ });
362
+ });
340
363
 
341
- test('does not render a target attribute if the element is not an anchor', () => {
342
- renderButton({
343
- href: 'http://hyphen.ai',
344
- target: '_blank',
345
- children: 'hey there',
346
- });
347
- const buttonElement = screen.getByRole('button');
348
- expect(buttonElement).not.toHaveAttribute('target');
349
- });
364
+ describe('Shadow Prop', () => {
365
+ test('applies shadow class when shadow prop is provided', () => {
366
+ renderButton({ shadow: 'lg', children: 'Shadow Button' });
367
+ const buttonElement = getButton('Shadow Button');
368
+ expect(buttonElement).toHaveClass('shadow-lg');
369
+ });
350
370
 
351
- test('does not render a target attribute if the element does not have an href', () => {
352
- renderButton({ as: 'a', target: '_blank', children: 'hey there' });
353
- const buttonElement = screen.getByText('hey there');
354
- expect(buttonElement).not.toHaveAttribute('target');
355
- });
371
+ test('applies responsive shadow classes', () => {
372
+ renderButton({
373
+ shadow: { base: 'sm', tablet: 'md', desktop: 'lg' },
374
+ children: 'Responsive Shadow Button',
375
+ });
376
+ const buttonElement = getButton('Responsive Shadow Button');
377
+ expect(buttonElement).toHaveClass(
378
+ 'shadow-sm',
379
+ 'shadow-md-tablet',
380
+ 'shadow-lg-desktop'
381
+ );
382
+ });
383
+ });
356
384
 
357
- describe('Rel Attribute', () => {
358
- test('applies rel attribute when target is _blank', () => {
359
- renderButton({
360
- as: 'a',
361
- href: 'http://hyphen.ai',
362
- target: '_blank',
363
- children: 'Link with rel',
364
- });
365
- const anchorElement = getAnchor('Link with rel');
366
- expect(anchorElement).toHaveAttribute('rel', 'noopener noreferrer');
367
- });
385
+ describe('As Child', () => {
386
+ test('renders as a different component when asChild is true', () => {
387
+ renderButton({
388
+ asChild: true,
389
+ children: <a href="https://ux.hyphen.ai">Link Button</a>,
368
390
  });
391
+ const linkElement = screen.getByRole('link');
392
+ expect(linkElement).toBeInTheDocument();
393
+ expect(linkElement).toHaveAttribute('href', 'https://ux.hyphen.ai');
369
394
  });
370
395
  });
371
396
 
372
- describe('React Router', () => {
373
- test('fires navigate callback when included', () => {
374
- const mockedNavigate = jest.fn();
375
- renderButton({
376
- as: 'a',
377
- navigate: mockedNavigate,
378
- href: '/',
379
- children: 'react router link',
380
- });
381
- const anchorElement = getAnchor('react router link');
382
- fireEvent.click(anchorElement);
383
- expect(mockedNavigate).toHaveBeenCalledTimes(1);
397
+ describe('Button Type', () => {
398
+ test('renders with type button', () => {
399
+ renderButton({ children: 'Default Type Button', type: 'button' });
400
+ const buttonElement = getButton('Default Type Button');
401
+ expect(buttonElement).toHaveAttribute('type', 'button');
384
402
  });
385
403
 
386
- test('does not fire navigate callback if target is _blank', () => {
387
- const mockedNavigate = jest.fn();
388
- renderButton({
389
- as: 'a',
390
- navigate: mockedNavigate,
391
- href: '/',
392
- target: '_blank',
393
- children: 'react router link',
394
- });
395
- const anchorElement = getAnchor('react router link');
396
- fireEvent.click(anchorElement);
397
- expect(mockedNavigate).toHaveBeenCalledTimes(0);
404
+ test('renders with type submit when specified', () => {
405
+ renderButton({ type: 'submit', children: 'Submit Button' });
406
+ const buttonElement = getButton('Submit Button');
407
+ expect(buttonElement).toHaveAttribute('type', 'submit');
408
+ });
409
+
410
+ test('renders with type reset when specified', () => {
411
+ renderButton({ type: 'reset', children: 'Reset Button' });
412
+ const buttonElement = getButton('Reset Button');
413
+ expect(buttonElement).toHaveAttribute('type', 'reset');
398
414
  });
399
415
  });
400
416
 
401
- describe('Role Attribute', () => {
402
- test('applies role attribute', () => {
403
- renderButton({ role: 'button', children: 'Button with Role' });
404
- const buttonElement = getButton('Button with Role');
405
- expect(buttonElement).toHaveAttribute('role', 'button');
417
+ describe('Button Ref', () => {
418
+ test('forwards ref to the button element', () => {
419
+ const ref = React.createRef<HTMLButtonElement>();
420
+ renderButton({ ref, children: 'Button with Ref' });
421
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement);
406
422
  });
407
423
  });
408
424
  });