agroptima-design-system 0.18.0 → 0.18.2

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": "agroptima-design-system",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "scripts": {
5
5
  "dev": "npm run storybook",
6
6
  "storybook": "storybook dev -p 6006 --ci",
@@ -10,7 +10,8 @@
10
10
  "lint:fix": "eslint src --fix",
11
11
  "types": "tsc --noEmit",
12
12
  "chromatic": "npx chromatic --exit-zero-on-changes",
13
- "test": "jest"
13
+ "test": "jest",
14
+ "publish:beta": "npm publish --tag beta"
14
15
  },
15
16
  "dependencies": {
16
17
  "@storybook/addon-designs": "^7.0.5",
@@ -8,6 +8,7 @@
8
8
  flex-direction: column;
9
9
  gap: config.$space-1x;
10
10
  padding: config.$space-3x;
11
+ width: 100%;
11
12
 
12
13
  p {
13
14
  margin: 0;
@@ -32,7 +33,14 @@
32
33
  flex-direction: row;
33
34
  justify-content: space-between;
34
35
  margin-bottom: config.$space-1x;
35
-
36
+ gap: config.$space-1x;
37
+ .title {
38
+ overflow: hidden;
39
+ text-overflow: ellipsis;
40
+ -webkit-line-clamp: 2;
41
+ -webkit-box-orient: vertical;
42
+ display: -webkit-box;
43
+ }
36
44
  > .bold {
37
45
  @include typography.body-bold;
38
46
  }
@@ -56,16 +64,14 @@
56
64
  &.disabled {
57
65
  @include typography.body-regular-disabled;
58
66
  background: color_alias.$neutral-color-50;
67
+ .header .bold {
68
+ color: color_alias.$neutral-color-400;
69
+ }
59
70
  }
60
- }
61
71
 
62
- // Media queries
63
- // Mobile case
64
- @media only screen and (min-width: breakpoints.$small) and (max-width: breakpoints.$medium) {
65
- width: 100%;
66
- }
67
- // Tablet & Desktop cases
68
- @media only screen and (min-width: breakpoints.$medium) {
69
- width: 18.625rem;
72
+ &.active {
73
+ border: 1px solid color_alias.$primary-color-1000;
74
+ box-shadow: none;
75
+ }
70
76
  }
71
77
  }
@@ -7,22 +7,25 @@ export type Variant = 'primary'
7
7
  export interface CardProps extends React.ComponentPropsWithoutRef<'div'> {
8
8
  variant?: Variant
9
9
  isDisabled?: boolean
10
+ isActive?: boolean
10
11
  }
11
12
 
12
13
  export function Card({
13
14
  className,
14
15
  variant = 'primary',
15
16
  isDisabled = false,
17
+ isActive = false,
16
18
  children,
17
19
  ...props
18
20
  }: CardProps): React.JSX.Element {
19
21
  const cssClasses = classNames('card', className, variant, {
20
22
  disabled: isDisabled,
23
+ active: isActive,
21
24
  })
22
25
 
23
26
  return (
24
- <div className={cssClasses} {...props}>
27
+ <article className={cssClasses} {...props}>
25
28
  {children}
26
- </div>
29
+ </article>
27
30
  )
28
31
  }
@@ -18,7 +18,7 @@ export function CardHeader({
18
18
 
19
19
  return (
20
20
  <div className={cssClasses} {...props}>
21
- <span className={classNames({ bold: isBold })}>{title}</span>
21
+ <span className={classNames('title', { bold: isBold })}>{title}</span>
22
22
  <div className="actions">{children}</div>
23
23
  </div>
24
24
  )
@@ -13,12 +13,16 @@
13
13
  gap: config.$space-3x;
14
14
  border-radius: config.$corner-radius-xxs;
15
15
  border-bottom: 1px solid color_alias.$neutral-color-200;
16
+ text-decoration: none;
16
17
  cursor: default;
17
-
18
+ &:hover {
19
+ text-decoration: none;
20
+ }
18
21
  @include typography.body-regular-primary;
19
22
 
20
23
  .icon {
21
24
  width: config.$icon-size-4x;
25
+ min-width: config.$icon-size-4x;
22
26
  height: config.$icon-size-4x;
23
27
  > svg {
24
28
  width: 100%;
@@ -54,6 +58,15 @@
54
58
  &.primary {
55
59
  background: color_alias.$neutral-white;
56
60
 
61
+ &.active {
62
+ background-color: color_alias.$primary-color-50;
63
+ }
64
+
65
+ &.error {
66
+ border: 1px solid color_alias.$error-color-600;
67
+ background-color: color_alias.$error-color-50;
68
+ }
69
+
57
70
  .icon {
58
71
  > svg {
59
72
  fill: color_alias.$primary-color-600;
@@ -1,17 +1,21 @@
1
1
  import { Icon, IconType } from '../Icon'
2
2
  import './CardMenu.scss'
3
3
  import { classNames } from '../../utils/classNames'
4
+ import Link, { LinkProps as NextLinkProps } from 'next/link'
4
5
 
5
6
  export type Variant = 'primary'
6
7
 
7
- export interface CardMenuOptionProps
8
- extends React.ComponentPropsWithoutRef<'div'> {
8
+ type LinkProps = NextLinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
9
+ export interface CardMenuOptionProps extends LinkProps {
9
10
  id?: string
10
11
  variant?: Variant
11
12
  icon: IconType
12
13
  title: string
13
14
  description?: string
14
15
  disabled?: boolean
16
+ href: string
17
+ active?: boolean
18
+ error?: boolean
15
19
  }
16
20
 
17
21
  export function CardMenuOption({
@@ -22,29 +26,35 @@ export function CardMenuOption({
22
26
  title,
23
27
  description,
24
28
  disabled,
29
+ href,
30
+ active,
31
+ error,
25
32
  ...props
26
33
  }: CardMenuOptionProps): React.JSX.Element {
27
34
  const cssClasses = classNames('card-menu-option', variant, className, {
28
- disabled: disabled,
35
+ disabled,
36
+ active,
37
+ error,
29
38
  })
30
39
 
31
40
  return (
32
- <div
41
+ <Link
33
42
  role="menuitem"
34
43
  className={cssClasses}
35
- {...props}
44
+ href={disabled ? '#' : href}
36
45
  aria-disabled={disabled}
46
+ {...props}
37
47
  >
38
48
  <div className="left">
39
49
  <div className="title-container">
40
50
  <Icon name={icon} className={variant} />
41
51
  <span className="title">{title}</span>
42
52
  </div>
43
- <p className="content">{description}</p>
53
+ {description && <p className="content">{description}</p>}
44
54
  </div>
45
55
  <div className="right">
46
56
  <Icon name="AngleRight" className={variant} />
47
57
  </div>
48
- </div>
58
+ </Link>
49
59
  )
50
60
  }
@@ -116,6 +116,7 @@
116
116
  }
117
117
 
118
118
  tr {
119
+ border: 1px solid transparent;
119
120
  td {
120
121
  @include typography.cards-table-list-text;
121
122
  overflow: hidden;
@@ -137,6 +138,13 @@
137
138
  @include typography.cards-table-list-disabled-text;
138
139
  }
139
140
  }
141
+ tr.active {
142
+ border-color: color_alias.$primary-color-1000;
143
+ box-shadow: none;
144
+ }
145
+ tr.action {
146
+ cursor: default;
147
+ }
140
148
  }
141
149
 
142
150
  // Desktop
@@ -1,19 +1,29 @@
1
1
  import React from 'react'
2
2
  import './CardsTable.scss'
3
+ import { classNames } from '../../utils/classNames'
3
4
 
4
5
  export interface CardsTableRowProps
5
6
  extends React.ComponentPropsWithoutRef<'tr'> {
6
7
  isDisabled?: boolean
8
+ isActive?: boolean
7
9
  }
8
10
 
9
11
  export function CardsTableRow({
10
- isDisabled = false,
12
+ isDisabled: disabled = false,
13
+ isActive: active = false,
11
14
  children,
12
15
  ...props
13
16
  }: CardsTableRowProps): React.JSX.Element {
14
- const disabledClass = isDisabled ? 'disabled' : ''
15
17
  return (
16
- <tr role="row" className={`row ${disabledClass}`} {...props}>
18
+ <tr
19
+ role="row"
20
+ className={classNames('row', {
21
+ disabled,
22
+ active,
23
+ action: Boolean(props.onClick),
24
+ })}
25
+ {...props}
26
+ >
17
27
  {children}
18
28
  </tr>
19
29
  )
@@ -1,5 +1,4 @@
1
- import React, { useState } from 'react'
2
- import { Icon } from './Icon'
1
+ import React from 'react'
3
2
  import { classNames } from '../utils/classNames'
4
3
  import './QuantitySelector.scss'
5
4
  import { Input, InputProps } from './Input'
@@ -7,28 +6,26 @@ import { Button, ButtonProps } from './Button'
7
6
 
8
7
  export type Variant = 'primary'
9
8
 
10
- export interface QuantitySelectorProps
11
- extends React.ComponentPropsWithoutRef<'div'> {
9
+ export interface QuantitySelectorProps extends InputProps {
12
10
  label: string
13
11
  accessibilityLabel?: string
14
12
  hideLabel?: boolean
15
13
  id?: string
16
14
  variant?: Variant
17
- decrementButton: ButtonProps
18
- incrementButton: ButtonProps
19
- quantityInput: InputProps
15
+ onDecrement: () => void
16
+ onIncrement: () => void
20
17
  }
21
18
 
22
19
  export function QuantitySelector({
23
- decrementButton,
24
- incrementButton,
25
- quantityInput,
20
+ id,
21
+ onDecrement,
22
+ onIncrement,
26
23
  label,
27
24
  accessibilityLabel,
28
- id,
29
25
  className,
30
26
  hideLabel = false,
31
27
  variant = 'primary',
28
+ disabled,
32
29
  ...props
33
30
  }: QuantitySelectorProps): React.JSX.Element {
34
31
  const cssClasses = classNames('quantity-selector', className)
@@ -41,13 +38,32 @@ export function QuantitySelector({
41
38
  </label>
42
39
  )}
43
40
  <div className={cssClasses}>
44
- <Button className="decrement-button" {...decrementButton} />
41
+ <Button
42
+ label=""
43
+ accessibilityLabel="-"
44
+ type="button"
45
+ leftIcon="Minus"
46
+ className="decrement-button"
47
+ disabled={disabled}
48
+ onClick={onDecrement}
49
+ />
45
50
  <Input
46
- {...quantityInput}
51
+ id={id}
47
52
  label={label}
48
53
  accessibilityLabel={accessibilityLabel}
54
+ disabled={disabled}
55
+ {...props}
56
+ hideLabel={true}
57
+ />
58
+ <Button
59
+ label=""
60
+ accessibilityLabel="+"
61
+ leftIcon="Add"
62
+ type="button"
63
+ className="increment-button"
64
+ disabled={disabled}
65
+ onClick={onIncrement}
49
66
  />
50
- <Button className="increment-button" {...incrementButton} />
51
67
  </div>
52
68
  </div>
53
69
  )
@@ -379,9 +379,16 @@ export const ProductCardsGroup = {
379
379
 
380
380
  export const Primary = {
381
381
  render: () => (
382
- <div style={{ display: 'flex' }}>
382
+ <div style={{ display: 'flex', width: '700px', gap: '20px' }}>
383
383
  <Card variant="primary">
384
- <CardHeader title="Tekken 8">
384
+ <CardHeader
385
+ isBold
386
+ title="TEKKEN 8 will feature exciting new gameplay focused on “Aggressive”
387
+ tactics. Retaining TEKKEN's unique fighting game identity, the game
388
+ will provide both players and spectators with the series' most
389
+ thrilling experience yet with visceral screen-jarring attacks and
390
+ environments that are both dynamic and destructible."
391
+ >
385
392
  <IconButton
386
393
  icon="Edit"
387
394
  accessibilityLabel="Edit game"
@@ -399,12 +406,26 @@ export const Primary = {
399
406
  />
400
407
  </CardHeader>
401
408
  <CardContent>
402
- <p>TEKKEN 8 will feature exciting new gameplay focused on “Aggressive” tactics. Retaining TEKKEN's unique fighting game identity, the game will provide both players and spectators with the series' most thrilling experience yet with visceral screen-jarring attacks and environments that are both dynamic and destructible.</p>
409
+ <p>
410
+ TEKKEN 8 will feature exciting new gameplay focused on “Aggressive”
411
+ tactics. Retaining TEKKEN's unique fighting game identity, the game
412
+ will provide both players and spectators with the series' most
413
+ thrilling experience yet with visceral screen-jarring attacks and
414
+ environments that are both dynamic and destructible.
415
+ </p>
403
416
  </CardContent>
404
417
  <CardFooter>
405
418
  <Button variant="primary-outlined" label="Add to wishlist" />
406
419
  </CardFooter>
407
420
  </Card>
421
+ <Card variant="primary">
422
+ <CardHeader isBold title="Metal Gear Solid 5" />
423
+ <CardContent>
424
+ <p>
425
+ Metal Gear Solid 5: Ground Zeroes + The Phantom Pain is a stealth
426
+ </p>
427
+ </CardContent>
428
+ </Card>
408
429
  </div>
409
430
  ),
410
431
  }
@@ -412,12 +433,18 @@ export const Primary = {
412
433
  export const Disabled = {
413
434
  render: () => (
414
435
  <div style={{ display: 'flex' }}>
415
- <Card isDisabled={true} variant="primary">
416
- <CardHeader title="Tekken 8">
436
+ <Card isDisabled variant="primary">
437
+ <CardHeader isBold title="Tekken 8">
417
438
  <IconButton disabled icon="Delete" accessibilityLabel="Delete game" />
418
439
  </CardHeader>
419
440
  <CardContent>
420
- <p>TEKKEN 8 will feature exciting new gameplay focused on “Aggressive” tactics. Retaining TEKKEN's unique fighting game identity, the game will provide both players and spectators with the series' most thrilling experience yet with visceral screen-jarring attacks and environments that are both dynamic and destructible.</p>
441
+ <p>
442
+ TEKKEN 8 will feature exciting new gameplay focused on “Aggressive”
443
+ tactics. Retaining TEKKEN's unique fighting game identity, the game
444
+ will provide both players and spectators with the series' most
445
+ thrilling experience yet with visceral screen-jarring attacks and
446
+ environments that are both dynamic and destructible.
447
+ </p>
421
448
  </CardContent>
422
449
  <CardFooter>
423
450
  <Button variant="primary-outlined" disabled label="Add to wishlist" />
@@ -426,3 +453,15 @@ export const Disabled = {
426
453
  </div>
427
454
  ),
428
455
  }
456
+
457
+ export const Active = {
458
+ render: () => (
459
+ <div style={{ display: 'flex' }}>
460
+ <Card isActive>
461
+ <CardHeader title="Fallout 3">
462
+ <IconButton icon="Delete" accessibilityLabel="Delete game" />
463
+ </CardHeader>
464
+ </Card>
465
+ </div>
466
+ ),
467
+ }
@@ -29,11 +29,14 @@ const meta = {
29
29
  description: {
30
30
  description: 'Component description text',
31
31
  },
32
- isDisabled: {
32
+ disabled: {
33
33
  description: 'Is the component disabled?',
34
34
  },
35
- onClick: {
36
- description: 'Event triggered when the component is clicked',
35
+ active: {
36
+ description: 'Is the component active?',
37
+ },
38
+ error: {
39
+ description: 'Is the component marked as error?',
37
40
  },
38
41
  },
39
42
  parameters: figmaPrimaryDesign,
@@ -45,12 +48,11 @@ export const Option = {
45
48
  render: () => (
46
49
  <CardMenuOption
47
50
  id="first-menu-option"
51
+ href="#"
48
52
  icon="Info"
49
53
  variant="primary"
50
54
  title="It's dangerous to go alone!"
51
55
  description="Take this 🗡️ and this 🛡️ and this 💣 and this 🏹 and this 🔪 and this 🐴 and this 🔫 and this 🔪"
52
- isDisabled={false}
53
- onClick={() => alert('click')}
54
56
  />
55
57
  ),
56
58
  }
@@ -59,6 +61,7 @@ export const DisabledOption = {
59
61
  render: () => (
60
62
  <CardMenuOption
61
63
  id="first-menu-option"
64
+ href="#"
62
65
  icon="Info"
63
66
  variant="primary"
64
67
  title="It's dangerous to go alone!"
@@ -68,35 +71,60 @@ export const DisabledOption = {
68
71
  ),
69
72
  }
70
73
 
74
+ export const ActiveOption = {
75
+ render: () => (
76
+ <CardMenuOption
77
+ id="first-menu-option"
78
+ href="#"
79
+ icon="Info"
80
+ variant="primary"
81
+ title="It's dangerous to go alone!"
82
+ description="Take this 🗡️"
83
+ active
84
+ />
85
+ ),
86
+ }
87
+
88
+ export const ErrorOption = {
89
+ render: () => (
90
+ <CardMenuOption
91
+ id="first-menu-option"
92
+ href="#"
93
+ icon="Info"
94
+ variant="primary"
95
+ title="It's dangerous to go alone!"
96
+ description="Take this 🗡️"
97
+ error
98
+ />
99
+ ),
100
+ }
101
+
71
102
  export const Menu = {
72
103
  render: () => (
73
104
  <CardMenu>
74
105
  <CardMenuOption
75
106
  id="first-menu-option"
107
+ href="#"
76
108
  icon="AddCircle"
77
109
  variant="primary"
78
110
  title="Title"
79
111
  description="Name of the videogame"
80
- isDisabled={false}
81
- onClick={() => alert('click title')}
82
112
  />
83
113
  <CardMenuOption
84
114
  id="second-menu-option"
115
+ href="#"
85
116
  icon="Edit"
86
117
  variant="primary"
87
118
  title="Address"
88
119
  description="Videogame company address"
89
- isDisabled={false}
90
- onClick={() => alert('click address')}
91
120
  />
92
121
  <CardMenuOption
93
122
  id="third-menu-option"
123
+ href="#"
94
124
  icon="Info"
95
125
  variant="primary"
96
126
  title="Email"
97
127
  description="Videogame company email"
98
- isDisabled={false}
99
- onClick={() => alert('click email')}
100
128
  />
101
129
  </CardMenu>
102
130
  ),
@@ -67,7 +67,7 @@ export const Primary = {
67
67
  </CardsTableCell>
68
68
  </CardsTableRow>
69
69
 
70
- <CardsTableRow>
70
+ <CardsTableRow isDisabled>
71
71
  <CardsTableCell titleWithActions={2}>The Witcher 3</CardsTableCell>
72
72
  <CardsTableCell>
73
73
  CD PROJEKT S.A. ul. Jagiellońska 74 03-301 Warszawa Poland
@@ -88,7 +88,7 @@ export const Primary = {
88
88
  </CardsTableCell>
89
89
  </CardsTableRow>
90
90
 
91
- <CardsTableRow>
91
+ <CardsTableRow isActive onClick={() => alert('Click')}>
92
92
  <CardsTableCell titleWithActions={1}>Tekken 8</CardsTableCell>
93
93
  <CardsTableCell>
94
94
  Bandai Namco Studios Inc. ; Address: 2-37-25 Eitai, Koto-ku, Tokyo
@@ -3,6 +3,13 @@ import { Meta } from "@storybook/addon-docs";
3
3
  <Meta title="Changelog" />
4
4
  # Changelog
5
5
 
6
+ ## 0.18.2
7
+ - Add link to CardMenuOption component
8
+ - Add active and error state to CardMenuOption component
9
+ - Add active state to Card component
10
+ - Update QuantitySelector component props
11
+ - Add active and action state to CardsTableRow component
12
+
6
13
  ## 0.18.0
7
14
  - Added Collapsible component to Storybook
8
15
 
@@ -51,29 +51,18 @@ export const Primary: Story = {
51
51
  args: {
52
52
  label: 'Quantity',
53
53
  accessibilityLabel: 'Quantity of items to wishlist',
54
- id: 'quantity-selector',
55
- decrementButton: {
56
- label: '',
57
- leftIcon: 'Minus',
58
- onClick: () => alert('decrement'),
59
- },
60
- incrementButton: {
61
- label: '',
62
- leftIcon: 'Add',
63
- onClick: () => alert('increment'),
64
- },
65
- quantityInput: {
66
- label: 'Quantity input',
67
- hideLabel: true,
68
- name: 'quantity',
69
- value: 1,
70
- onChange: () => alert('onChange'),
71
- type: 'number',
72
- max: 10,
73
- step: 0.0001,
74
- min: 1,
75
- required: true,
76
- },
54
+ id: 'quantity',
55
+ hideLabel: true,
56
+ name: 'quantity',
57
+ value: 1,
58
+ onChange: () => alert('onChange'),
59
+ type: 'number',
60
+ max: 10,
61
+ step: 0.0001,
62
+ min: 1,
63
+ required: true,
64
+ onDecrement: () => alert('decrement'),
65
+ onIncrement: () => alert('increment'),
77
66
  },
78
67
  parameters: figmaPrimaryDesign,
79
68
  }
@@ -82,32 +71,18 @@ export const Disabled: Story = {
82
71
  args: {
83
72
  label: 'Quantity',
84
73
  accessibilityLabel: 'Quantity of items to wishlist',
85
- id: 'quantity-selector',
86
- decrementButton: {
87
- label: '',
88
- leftIcon: 'Minus',
89
- onClick: () => alert('decrement'),
90
- disabled: true,
91
- },
92
- incrementButton: {
93
- label: '',
94
- leftIcon: 'Add',
95
- onClick: () => alert('increment'),
96
- disabled: true,
97
- },
98
- quantityInput: {
99
- label: 'Quantity input',
100
- hideLabel: true,
101
- name: 'quantity',
102
- value: 1,
103
- onChange: () => alert('onChange'),
104
- type: 'number',
105
- max: 10,
106
- step: 0.0001,
107
- min: 1,
108
- required: true,
109
- disabled: true,
110
- },
74
+ id: 'quantity',
75
+ name: 'quantity',
76
+ value: 1,
77
+ onChange: () => alert('onChange'),
78
+ type: 'number',
79
+ max: 10,
80
+ step: 0.0001,
81
+ min: 1,
82
+ required: true,
83
+ disabled: true,
84
+ onDecrement: () => alert('decrement'),
85
+ onIncrement: () => alert('increment'),
111
86
  },
112
87
  parameters: figmaPrimaryDesign,
113
88
  }
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { screen, render } from '@testing-library/react'
2
+ import { render } from '@testing-library/react'
3
3
  import { CardMenuOption } from '@/atoms/CardMenu/CardMenuOption'
4
4
 
5
5
  describe('CardMenuOption', () => {
@@ -7,6 +7,7 @@ describe('CardMenuOption', () => {
7
7
  const { getByRole, getByText, getAllByRole } = render(
8
8
  <CardMenuOption
9
9
  id="option-one"
10
+ href="#"
10
11
  icon="Info"
11
12
  title="It's dangerous to go alone!"
12
13
  description="Take this sword!"
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { screen, render } from '@testing-library/react'
2
+ import { render } from '@testing-library/react'
3
3
  import { QuantitySelector } from '@/atoms/QuantitySelector'
4
4
 
5
5
  describe('QuantitySelector', () => {
@@ -8,27 +8,17 @@ describe('QuantitySelector', () => {
8
8
  <QuantitySelector
9
9
  label="Quantity"
10
10
  accessibilityLabel="Quantity of items to wishlist"
11
- id="quantity-selector"
12
- decrementButton={{
13
- label: '-',
14
- onClick: () => alert('decrement'),
15
- }}
16
- incrementButton={{
17
- label: '+',
18
- onClick: () => alert('increment'),
19
- }}
20
- quantityInput={{
21
- label: 'Quantity input',
22
- hideLabel: true,
23
- name: 'quantity',
24
- value: 1,
25
- onChange: () => alert('onChange'),
26
- type: 'number',
27
- max: 10,
28
- step: 0.0001,
29
- min: 1,
30
- required: true,
31
- }}
11
+ id="quantity"
12
+ onDecrement={() => alert('decrement')}
13
+ onIncrement={() => alert('increment')}
14
+ name="quantity"
15
+ value={1}
16
+ onChange={() => alert('onChange')}
17
+ type="number"
18
+ max={10}
19
+ step={0.0001}
20
+ min={1}
21
+ required={true}
32
22
  />,
33
23
  )
34
24