@iota/apps-ui-kit 0.11.0 → 0.13.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 (30) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/index.d.ts +43 -14
  3. package/dist/index.es.js +1281 -1214
  4. package/dist/index.es.js.map +1 -1
  5. package/dist/index.umd.js +15 -15
  6. package/dist/index.umd.js.map +1 -1
  7. package/dist/style.css +1 -1
  8. package/package.json +2 -8
  9. package/src/lib/components/atoms/badge/Badge.tsx +18 -2
  10. package/src/lib/components/atoms/button/Button.tsx +1 -1
  11. package/src/lib/components/atoms/checkbox/Checkbox.tsx +2 -2
  12. package/src/lib/components/atoms/divider/Divider.tsx +2 -3
  13. package/src/lib/components/atoms/info-box/InfoBox.tsx +1 -1
  14. package/src/lib/components/atoms/key-value-info/KeyValueInfo.tsx +20 -5
  15. package/src/lib/components/atoms/tooltip/Tooltip.tsx +89 -21
  16. package/src/lib/components/molecules/card/CardAction.tsx +2 -2
  17. package/src/lib/components/molecules/card/CardBody.tsx +1 -1
  18. package/src/lib/components/molecules/display-stats/DisplayStats.tsx +2 -2
  19. package/src/lib/components/molecules/input/Input.tsx +2 -2
  20. package/src/lib/components/molecules/input/InputWrapper.tsx +3 -2
  21. package/src/lib/components/molecules/select/Select.tsx +1 -2
  22. package/src/lib/components/molecules/table-cell/TableCell.tsx +6 -0
  23. package/src/lib/components/organisms/accordion/Accordion.tsx +10 -1
  24. package/src/lib/components/organisms/table/Table.tsx +31 -3
  25. package/src/lib/constants/index.ts +1 -0
  26. package/src/lib/constants/interactive.constants.ts +19 -0
  27. package/src/lib/styles/base.css +3 -2
  28. package/src/lib/tailwind/base.presets.ts +2 -2
  29. package/src/lib/tailwind/plugins.ts +14 -0
  30. package/src/storybook/stories/atoms/Panel.stories.tsx +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iota/apps-ui-kit",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "sideEffects": false,
5
5
  "repository": "https://github.com/iotaledger/ts-packages",
6
6
  "license": "Apache-2.0",
@@ -27,7 +27,7 @@
27
27
  "classnames": "^2.5.1",
28
28
  "lodash.merge": "^4.6.2",
29
29
  "react-number-format": "^5.2.2",
30
- "@iota/apps-ui-icons": "0.7.5"
30
+ "@iota/apps-ui-icons": "0.7.6"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": "^18 || ^19",
@@ -58,12 +58,6 @@
58
58
  "vite-plugin-dts": "^4.5.4"
59
59
  },
60
60
  "scripts": {
61
- "prettier:check": "prettier -c --ignore-unknown --ignore-path=../../.prettierignore --ignore-path=.prettierignore .",
62
- "prettier:fix": "prettier -w --ignore-unknown --ignore-path=../../.prettierignore --ignore-path=.prettierignore .",
63
- "eslint:check": "eslint --max-warnings=0 .",
64
- "eslint:fix": "pnpm run eslint:check --fix",
65
- "lint": "pnpm run eslint:check && pnpm run prettier:check",
66
- "lint:fix": "pnpm run eslint:fix && pnpm run prettier:fix",
67
61
  "storybook": "storybook dev -p 6006",
68
62
  "build-storybook": "storybook build",
69
63
  "build-package": "tsc --project tsconfig.package.json && vite build",
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import cx from 'classnames';
5
+ import type { ReactNode } from 'react';
5
6
  import { BadgeSize, BadgeType } from './badge.enums';
6
7
  import { BACKGROUND_COLORS, BORDER_COLORS, TEXT_COLORS } from './badge.classes';
7
8
 
@@ -18,9 +19,18 @@ interface BadgeProps {
18
19
  * The size of the badge
19
20
  */
20
21
  size?: BadgeSize;
22
+ /**
23
+ * An icon rendered before the label.
24
+ */
25
+ icon?: ReactNode;
21
26
  }
22
27
 
23
- export function Badge({ type, label, size = BadgeSize.Medium }: BadgeProps): React.JSX.Element {
28
+ export function Badge({
29
+ type,
30
+ label,
31
+ size = BadgeSize.Medium,
32
+ icon,
33
+ }: BadgeProps): React.JSX.Element {
24
34
  const backgroundClasses = BACKGROUND_COLORS[type];
25
35
  const textClasses = TEXT_COLORS[type];
26
36
  const isSmall = size === BadgeSize.Small;
@@ -31,7 +41,8 @@ export function Badge({ type, label, size = BadgeSize.Medium }: BadgeProps): Rea
31
41
  return (
32
42
  <div
33
43
  className={cx(
34
- 'inline-flex items-center space-x-2 rounded-full disabled:opacity-30',
44
+ 'inline-flex items-center rounded-full disabled:opacity-30',
45
+ icon ? 'gap-xxs' : 'space-x-2',
35
46
  { border: !isSmall },
36
47
  { [BORDER_COLORS[type]]: !isSmall },
37
48
  { 'badge-ring-outlined': isSmall && isOutlined },
@@ -39,6 +50,11 @@ export function Badge({ type, label, size = BadgeSize.Medium }: BadgeProps): Rea
39
50
  labelClasses,
40
51
  )}
41
52
  >
53
+ {icon && (
54
+ <span className={cx('flex h-4 w-4 items-center justify-center', textClasses)}>
55
+ {icon}
56
+ </span>
57
+ )}
42
58
  <span className={cx(textSizeClass, textClasses)}>{label}</span>
43
59
  </div>
44
60
  );
@@ -26,7 +26,7 @@ export interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonE
26
26
  /**
27
27
  * The text of the button.
28
28
  */
29
- text?: string;
29
+ text?: React.ReactNode;
30
30
  /**
31
31
  The icon of the button
32
32
  */
@@ -98,7 +98,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
98
98
  <span
99
99
  onClick={() => inputRef.current?.click()}
100
100
  className={cx(
101
- 'checkbox-base checkbox-state checkbox-icon checkbox-icon-hidden',
101
+ 'checkbox-base checkbox-state checkbox-icon checkbox-icon-hidden shrink-0',
102
102
  'checkbox-border-default',
103
103
  'peer-[&:is(:checked,:indeterminate)]:checkbox-border-checked',
104
104
  'peer-[&:is(:checked,:indeterminate)]:checkbox-bg-checked',
@@ -119,7 +119,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
119
119
 
120
120
  function LabelText({ label, name }: Pick<CheckboxProps, 'label' | 'name'>) {
121
121
  return (
122
- <label htmlFor={name} className="checkbox-label checkbox-label-disabled">
122
+ <label htmlFor={name} className="checkbox-label checkbox-label-disabled flex-1">
123
123
  {label}
124
124
  </label>
125
125
  );
@@ -33,9 +33,8 @@ export function Divider({
33
33
  lineHeight = DEFAULT_LINE_HEIGHT,
34
34
  }: DividerProps): React.JSX.Element {
35
35
  // Set height and width of divider line based on type
36
- const lineStyle = {
37
- ...(type === DividerType.Horizontal ? { height: lineHeight } : { width: lineHeight }),
38
- };
36
+ const lineStyle =
37
+ type === DividerType.Horizontal ? { height: lineHeight } : { width: lineHeight };
39
38
 
40
39
  let dividerSize = DIVIDER_FULL_WIDTH[type];
41
40
  if (width && type === DividerType.Horizontal) {
@@ -59,7 +59,7 @@ export function InfoBox({
59
59
  {icon}
60
60
  </span>
61
61
  )}
62
- <div className="flex flex-col gap-1">
62
+ <div className="flex min-w-0 flex-col gap-1 [overflow-wrap:anywhere]">
63
63
  {title && <span className="infobox-text-title text-title-sm">{title}</span>}
64
64
  {supportingText && (
65
65
  <span className="infobox-supporting-text text-body-sm">{supportingText}</span>
@@ -13,7 +13,7 @@ interface KeyValueProps {
13
13
  /**
14
14
  * The key of the KeyValue.
15
15
  */
16
- keyText: string;
16
+ keyText: ReactNode;
17
17
  /**
18
18
  * An icon shown next to the key text (optional).
19
19
  */
@@ -62,6 +62,11 @@ interface KeyValueProps {
62
62
  * Reverse the KeyValue (optional).
63
63
  */
64
64
  isReverse?: boolean;
65
+ /**
66
+ * Use a receipt-style row with a dotted leader and right-aligned value.
67
+ * This is opt-in so existing KeyValueInfo layouts remain unchanged.
68
+ */
69
+ layout?: 'default' | 'receipt';
65
70
  /**
66
71
  * Text shown on value hover.
67
72
  */
@@ -82,9 +87,11 @@ export function KeyValueInfo({
82
87
  onCopyError,
83
88
  fullwidth,
84
89
  isReverse = false,
90
+ layout = 'default',
85
91
  valueHoverTitle,
86
92
  }: KeyValueProps): React.JSX.Element {
87
93
  const flexDirectionClass = isReverse ? 'flex-row-reverse' : 'flex-row';
94
+ const isReceiptLayout = layout === 'receipt';
88
95
  async function handleCopyClick(event: React.MouseEvent<HTMLButtonElement>) {
89
96
  if (!navigator.clipboard) {
90
97
  return;
@@ -112,12 +119,12 @@ export function KeyValueInfo({
112
119
  )}
113
120
  >
114
121
  <div
115
- className={cx('flex shrink-0 flex-row items-center gap-x-0.5', {
116
- 'w-1/4': !fullwidth,
122
+ className={cx('flex max-w-full shrink-0 flex-row items-center gap-x-0.5', {
123
+ 'w-1/4': !fullwidth && !isReceiptLayout,
117
124
  })}
118
125
  >
119
126
  {keyIcon}
120
- <span className="key-value-key-text-color break-normal text-body-md">
127
+ <span className="key-value-key-text-color min-w-0 break-words text-body-md">
121
128
  {keyText}
122
129
  </span>
123
130
  {tooltipText && (
@@ -126,10 +133,18 @@ export function KeyValueInfo({
126
133
  </Tooltip>
127
134
  )}
128
135
  </div>
136
+ {isReceiptLayout && !fullwidth && (
137
+ <span
138
+ aria-hidden="true"
139
+ className="mb-1 min-w-[0.5rem] flex-1 border-b border-dotted border-iota-neutral-80 opacity-60 dark:border-iota-neutral-20"
140
+ />
141
+ )}
129
142
  <div
130
143
  className={cx('flex min-w-0 flex-row items-baseline gap-1 break-all', {
131
- 'w-3/4': !fullwidth,
144
+ 'w-3/4': !fullwidth && !isReceiptLayout,
145
+ 'max-w-[60%] justify-end text-right': !fullwidth && isReceiptLayout,
132
146
  'flex-wrap': fullwidth,
147
+ 'justify-end text-right': fullwidth && isReceiptLayout,
133
148
  truncate: isTruncated,
134
149
  })}
135
150
  >
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { useRef, useState, useLayoutEffect } from 'react';
5
- import type { PropsWithChildren, ReactNode } from 'react';
5
+ import type { MouseEvent, PropsWithChildren, ReactNode } from 'react';
6
6
  import { createPortal } from 'react-dom';
7
7
  import cx from 'classnames';
8
8
  import { TooltipPosition } from './tooltip.enums';
@@ -16,6 +16,13 @@ interface TooltipProps {
16
16
  closeDelay?: number;
17
17
  }
18
18
 
19
+ const OPPOSITE_POSITION: Record<TooltipPosition, TooltipPosition> = {
20
+ [TooltipPosition.Top]: TooltipPosition.Bottom,
21
+ [TooltipPosition.Bottom]: TooltipPosition.Top,
22
+ [TooltipPosition.Left]: TooltipPosition.Right,
23
+ [TooltipPosition.Right]: TooltipPosition.Left,
24
+ };
25
+
19
26
  export function Tooltip({
20
27
  text,
21
28
  position = TooltipPosition.Top,
@@ -29,7 +36,7 @@ export function Tooltip({
29
36
  const tooltipRef = useRef<HTMLDivElement>(null);
30
37
 
31
38
  const [visible, setVisible] = useState(false);
32
- const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
39
+ const [coords, setCoords] = useState({ top: 0, left: 0, overlapsTrigger: false });
33
40
 
34
41
  const openTimer = useRef<ReturnType<typeof setTimeout>>();
35
42
  const closeTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -49,54 +56,110 @@ export function Tooltip({
49
56
  closeTimer.current = setTimeout(() => setVisible(false), closeDelay);
50
57
  };
51
58
 
52
- useLayoutEffect(() => {
53
- if (!visible) return;
54
-
55
- const rect = triggerRef.current?.getBoundingClientRect();
56
- if (!rect) return;
57
-
58
- const pos = {
59
+ const computePosition = (rect: DOMRect, placement: TooltipPosition) =>
60
+ ({
59
61
  [TooltipPosition.Top]: {
60
62
  top: rect.top - offset,
61
63
  left: rect.left + rect.width / 2,
62
64
  transform: 'translate(-50%, -100%)',
65
+ anchorX: 0.5,
66
+ anchorY: 1,
63
67
  },
64
68
  [TooltipPosition.Bottom]: {
65
69
  top: rect.bottom + offset,
66
70
  left: rect.left + rect.width / 2,
67
71
  transform: 'translate(-50%, 0)',
72
+ anchorX: 0.5,
73
+ anchorY: 0,
68
74
  },
69
75
  [TooltipPosition.Left]: {
70
76
  top: rect.top + rect.height / 2,
71
77
  left: rect.left - offset,
72
78
  transform: 'translate(-100%, -50%)',
79
+ anchorX: 1,
80
+ anchorY: 0.5,
73
81
  },
74
82
  [TooltipPosition.Right]: {
75
83
  top: rect.top + rect.height / 2,
76
84
  left: rect.right + offset,
77
85
  transform: 'translate(0, -50%)',
86
+ anchorX: 0,
87
+ anchorY: 0.5,
78
88
  },
79
- }[position];
89
+ })[placement];
90
+
91
+ const clampDelta = (boxStart: number, size: number, viewportSize: number, margin: number) => {
92
+ const boxEnd = boxStart + size;
93
+ if (boxEnd > viewportSize - margin) return viewportSize - margin - boxEnd;
94
+ if (boxStart < margin) return margin - boxStart;
95
+ return 0;
96
+ };
97
+
98
+ useLayoutEffect(() => {
99
+ if (!visible) return;
80
100
 
81
- setCoords({ top: pos.top, left: pos.left });
82
- tooltipRef.current!.style.transform = pos.transform;
101
+ const rect = triggerRef.current?.getBoundingClientRect();
102
+ const tooltipEl = tooltipRef.current;
103
+ if (!rect || !tooltipEl) return;
104
+
105
+ const { width, height } = tooltipEl.getBoundingClientRect();
106
+ const margin = 8;
107
+ const isVertical = position === TooltipPosition.Top || position === TooltipPosition.Bottom;
108
+
109
+ const fitsMainAxis = (pos: ReturnType<typeof computePosition>) =>
110
+ isVertical
111
+ ? clampDelta(pos.top - height * pos.anchorY, height, window.innerHeight, margin) ===
112
+ 0
113
+ : clampDelta(pos.left - width * pos.anchorX, width, window.innerWidth, margin) ===
114
+ 0;
115
+
116
+ let pos = computePosition(rect, position);
117
+ if (!fitsMainAxis(pos)) {
118
+ const flipped = computePosition(rect, OPPOSITE_POSITION[position]);
119
+ if (fitsMainAxis(flipped)) pos = flipped;
120
+ }
121
+ tooltipEl.style.transform = pos.transform;
122
+
123
+ const boxLeft = pos.left - width * pos.anchorX;
124
+ const boxTop = pos.top - height * pos.anchorY;
125
+
126
+ const deltaX = clampDelta(boxLeft, width, window.innerWidth, margin);
127
+ const deltaY = clampDelta(boxTop, height, window.innerHeight, margin);
128
+
129
+ const left = boxLeft + deltaX;
130
+ const top = boxTop + deltaY;
131
+ const overlapsTrigger =
132
+ left < rect.right &&
133
+ left + width > rect.left &&
134
+ top < rect.bottom &&
135
+ top + height > rect.top;
136
+
137
+ setCoords({ top: pos.top + deltaY, left: pos.left + deltaX, overlapsTrigger });
83
138
  }, [visible, position, offset]);
84
139
 
85
140
  useLayoutEffect(() => {
86
141
  if (!visible) return;
87
- const update = () => {
88
- const rect = triggerRef.current?.getBoundingClientRect();
89
- if (!rect) return;
90
- setCoords((prev) => ({ ...prev, left: rect.left + rect.width / 2 }));
142
+ const dismiss = () => {
143
+ clearTimers();
144
+ setVisible(false);
91
145
  };
92
- window.addEventListener('scroll', update, true);
93
- window.addEventListener('resize', update);
146
+ window.addEventListener('scroll', dismiss, true);
147
+ window.addEventListener('resize', dismiss);
94
148
  return () => {
95
- window.removeEventListener('scroll', update, true);
96
- window.removeEventListener('resize', update);
149
+ window.removeEventListener('scroll', dismiss, true);
150
+ window.removeEventListener('resize', dismiss);
97
151
  };
98
152
  }, [visible]);
99
153
 
154
+ // Keeps a link or button wrapping the trigger from acting on clicks meant to open the tooltip.
155
+ const handleTriggerClick = (event: MouseEvent<HTMLDivElement>) => {
156
+ const interactive = (event.target as Element).closest('a, button');
157
+ if (!interactive || triggerRef.current?.contains(interactive)) return;
158
+ event.preventDefault();
159
+ event.stopPropagation();
160
+ open();
161
+ };
162
+
100
163
  // z-[9999999999]: needed because we must exceed the popup’s ≈2 147 483 647 z-index;
101
164
  // otherwise the tooltip renders but stays invisible inside a Chrome extension
102
165
  const base = 'z-[9999999999] w-max rounded p-xs tooltip-bg tooltip-text-color';
@@ -110,6 +173,7 @@ export function Tooltip({
110
173
  onFocus={open}
111
174
  onMouseLeave={close}
112
175
  onBlur={close}
176
+ onClick={handleTriggerClick}
113
177
  >
114
178
  {children}
115
179
  </div>
@@ -126,7 +190,11 @@ export function Tooltip({
126
190
  transition: 'opacity .15s ease',
127
191
  opacity: 1,
128
192
  }}
129
- className={cx(base, maxWidth)}
193
+ className={cx(
194
+ base,
195
+ maxWidth,
196
+ coords.overlapsTrigger && 'pointer-events-none',
197
+ )}
130
198
  onMouseEnter={open}
131
199
  onMouseLeave={close}
132
200
  >
@@ -6,7 +6,7 @@ import { CardActionType } from './card.enums';
6
6
  import { Button, ButtonSize, ButtonType } from '@/components/atoms/button';
7
7
 
8
8
  export type CardActionProps = {
9
- title?: string;
9
+ title?: React.ReactNode;
10
10
  subtitle?: string | React.ReactNode;
11
11
  type: CardActionType;
12
12
  onClick?: () => void;
@@ -51,7 +51,7 @@ export function CardAction({
51
51
  <div className="card-action-title-color font-inter text-label-md">{title}</div>
52
52
  )}
53
53
  {subtitle && (
54
- <div className="card-action-subtitle-color font-inter text-label-sm">
54
+ <div className="card-action-subtitle-color flex flex-row justify-end font-inter text-label-sm">
55
55
  {subtitle}
56
56
  </div>
57
57
  )}
@@ -6,7 +6,7 @@ import { Tooltip } from '@/components/atoms/tooltip';
6
6
  import type { TooltipPosition } from '@/components/atoms/tooltip';
7
7
 
8
8
  export type CardBodyProps = {
9
- title: string;
9
+ title: ReactNode;
10
10
  subtitle?: string | ReactNode;
11
11
  clickableAction?: React.ReactNode;
12
12
  icon?: React.ReactNode;
@@ -28,7 +28,7 @@ interface DisplayStatsProps {
28
28
  /**
29
29
  * The tooltip text.
30
30
  */
31
- tooltipText?: string;
31
+ tooltipText?: ReactNode;
32
32
  /**
33
33
  * The value of the stats.
34
34
  */
@@ -123,7 +123,7 @@ export function DisplayStats({
123
123
  {icon && <span className="display-stats-icon-color">{icon}</span>}
124
124
  </div>
125
125
  <div className="flex w-full flex-row items-baseline gap-xxs">
126
- <span className={cx('break-all', valueClass)}>{value}</span>
126
+ <span className={cx('min-w-0 break-all', valueClass)}>{value}</span>
127
127
  {supportingLabel && (
128
128
  <span className={cx('opacity-40', supportingLabelTextClass)}>
129
129
  {supportingLabel}
@@ -31,7 +31,7 @@ export interface BaseInputProps extends InputWrapperProps {
31
31
  /**
32
32
  * Amount counter that is shown at the side of the caption text.
33
33
  */
34
- amountCounter?: string | number;
34
+ amountCounter?: React.ReactNode;
35
35
  /**
36
36
  * Trailing element that is shown after the input
37
37
  */
@@ -47,7 +47,7 @@ export interface BaseInputProps extends InputWrapperProps {
47
47
  /**
48
48
  * Text that is shown below the value of the input.
49
49
  */
50
- supportingValue?: string | null;
50
+ supportingValue?: React.ReactNode;
51
51
  /**
52
52
  * Default value of the input
53
53
  */
@@ -5,6 +5,7 @@ import cx from 'classnames';
5
5
  import { SecondaryText } from '@/components/atoms/secondary-text';
6
6
  import { LABEL_CLASSES } from './input.classes';
7
7
  import { createElement } from 'react';
8
+ import type { ReactNode } from 'react';
8
9
 
9
10
  export enum LabelHtmlTag {
10
11
  Label = 'label',
@@ -19,7 +20,7 @@ export interface InputWrapperProps {
19
20
  /**
20
21
  * Shows a caption with the text below the input.
21
22
  */
22
- caption?: string;
23
+ caption?: ReactNode;
23
24
  /**
24
25
  * Error Message. Overrides the caption.
25
26
  */
@@ -27,7 +28,7 @@ export interface InputWrapperProps {
27
28
  /**
28
29
  * Amount counter that is shown at the side of the caption text.
29
30
  */
30
- amountCounter?: string | number;
31
+ amountCounter?: React.ReactNode;
31
32
  /**
32
33
  * Is the input required
33
34
  */
@@ -17,8 +17,7 @@ export type SelectOption =
17
17
  | { id: string; label: React.ReactNode };
18
18
 
19
19
  interface SelectProps
20
- extends Pick<React.HTMLProps<HTMLSelectElement>, 'disabled'>,
21
- React.AriaAttributes {
20
+ extends Pick<React.HTMLProps<HTMLSelectElement>, 'disabled'>, React.AriaAttributes {
22
21
  /**
23
22
  * The selected option value.
24
23
  */
@@ -17,15 +17,21 @@ interface TableCellBaseProps {
17
17
  * Whether the cell content should be centered.
18
18
  */
19
19
  isContentCentered?: boolean;
20
+ /**
21
+ * The number of columns the cell should span.
22
+ */
23
+ colSpan?: number;
20
24
  }
21
25
 
22
26
  export function TableCellBase({
23
27
  children,
24
28
  hasLastBorderNoneClass,
25
29
  isContentCentered,
30
+ colSpan,
26
31
  }: PropsWithChildren<TableCellBaseProps>) {
27
32
  return (
28
33
  <td
34
+ colSpan={colSpan}
29
35
  className={cx(
30
36
  'table-cell-border-color h-14 border-b px-md',
31
37
  { 'last:border-none': hasLastBorderNoneClass },
@@ -30,6 +30,13 @@ export interface AccordionHeaderProps {
30
30
  * Flag for hiding border.
31
31
  */
32
32
  hideBorder?: boolean;
33
+
34
+ /**
35
+ * Disable the header state layer while an interactive child is active.
36
+ * This keeps the parent hover treatment from competing with child links
37
+ * and controls.
38
+ */
39
+ disableStateLayer?: boolean;
33
40
  }
34
41
 
35
42
  interface AccordionContentProps {
@@ -44,6 +51,7 @@ export function AccordionHeader({
44
51
  children,
45
52
  isExpanded,
46
53
  hideArrow,
54
+ disableStateLayer = false,
47
55
  }: PropsWithChildren<AccordionHeaderProps>) {
48
56
  return (
49
57
  <div
@@ -51,7 +59,8 @@ export function AccordionHeader({
51
59
  role="button"
52
60
  aria-expanded={isExpanded}
53
61
  className={cx(
54
- 'state-layer relative flex cursor-pointer items-center justify-between gap-md overflow-hidden rounded-xl py-sm--rs',
62
+ 'relative flex cursor-pointer items-center justify-between gap-md overflow-hidden rounded-xl py-sm--rs',
63
+ !disableStateLayer && 'state-layer',
55
64
  {
56
65
  'pr-md--rs': !hideArrow,
57
66
  },
@@ -8,6 +8,7 @@ import type { ButtonProps } from '@/components/atoms';
8
8
  import { Button, ButtonSize, ButtonType, Checkbox } from '@/components/atoms';
9
9
  import { TableCellBase, TableHeaderCell } from '@/components/molecules';
10
10
  import { ArrowLeft, DoubleArrowLeft, ArrowRight, DoubleArrowRight } from '@iota/apps-ui-icons';
11
+ import { INTERACTIVE_ELEMENT_SELECTOR } from '@/lib/constants';
11
12
 
12
13
  export interface TablePaginationOptions {
13
14
  /**
@@ -147,7 +148,7 @@ export function Table({
147
148
  />
148
149
  </>
149
150
  )}
150
- {action && action}
151
+ {action}
151
152
  </div>
152
153
  )}
153
154
  {supportingLabel || pageSizeSelector ? (
@@ -174,12 +175,39 @@ export function TableHeader({ children }: PropsWithChildren): JSX.Element {
174
175
  return <thead>{children}</thead>;
175
176
  }
176
177
 
178
+ function isRowClickIgnored(event: React.MouseEvent<HTMLTableRowElement>): boolean {
179
+ const interactiveElement =
180
+ event.target instanceof Element && event.target.closest(INTERACTIVE_ELEMENT_SELECTOR);
181
+ if (interactiveElement && event.currentTarget.contains(interactiveElement)) return true;
182
+ return !!window.getSelection()?.toString();
183
+ }
184
+
177
185
  export function TableRow({
178
186
  children,
179
187
  leading,
180
- }: PropsWithChildren<{ leading?: React.ReactNode }>): JSX.Element {
188
+ onClick,
189
+ onAuxClick,
190
+ }: PropsWithChildren<{
191
+ leading?: React.ReactNode;
192
+ onClick?: React.MouseEventHandler<HTMLTableRowElement>;
193
+ onAuxClick?: React.MouseEventHandler<HTMLTableRowElement>;
194
+ }>): JSX.Element {
195
+ function withRowClickGuard(handler?: React.MouseEventHandler<HTMLTableRowElement>) {
196
+ if (!handler) return undefined;
197
+ return (event: React.MouseEvent<HTMLTableRowElement>) => {
198
+ if (!isRowClickIgnored(event)) handler(event);
199
+ };
200
+ }
201
+
181
202
  return (
182
- <tr>
203
+ <tr
204
+ onClick={withRowClickGuard(onClick)}
205
+ onAuxClick={withRowClickGuard(onAuxClick)}
206
+ className={cx({
207
+ 'cursor-pointer row-hover:bg-shader-neutral-light-8 dark:row-hover:bg-shader-neutral-dark-8 inner-link:-mx-xxs inner-link:rounded inner-link:px-xxs inner-link:transition-colors inner-link-hover:bg-iota-primary-90/30 inner-link-hover:ring-1 inner-link-hover:ring-iota-primary-70/60 dark:inner-link-hover:bg-iota-primary-70/30':
208
+ onClick,
209
+ })}
210
+ >
183
211
  {leading}
184
212
  {children}
185
213
  </tr>
@@ -2,3 +2,4 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  export * from './colors.constants';
5
+ export * from './interactive.constants';
@@ -0,0 +1,19 @@
1
+ // Copyright (c) 2026 IOTA Stiftung
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ export const INTERACTIVE_ELEMENT_SELECTOR = [
5
+ 'a',
6
+ 'button',
7
+ 'input',
8
+ 'select',
9
+ 'textarea',
10
+ 'label',
11
+ '[role="button"]',
12
+ '[role="checkbox"]',
13
+ '[role="link"]',
14
+ '[role="switch"]',
15
+ ].join(',');
16
+
17
+ export const ROW_LINK_ATTRIBUTE = 'data-row-link';
18
+
19
+ export const ROW_LINK_PROPS = { [ROW_LINK_ATTRIBUTE]: true } as const;
@@ -40,8 +40,9 @@
40
40
  }
41
41
 
42
42
  :root.names {
43
- --roboto-flex-variation-settings: 'GRAD' 0, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750,
44
- 'YTDE' -203, 'YTFI' 738, 'YTLC' 514, 'YTUC' 640, 'slnt' 0, 'wdth' 130;
43
+ --roboto-flex-variation-settings:
44
+ 'GRAD' 0, 'XOPQ' 96, 'XTRA' 468, 'YOPQ' 79, 'YTAS' 750, 'YTDE' -203, 'YTFI' 738,
45
+ 'YTLC' 514, 'YTUC' 640, 'slnt' 0, 'wdth' 130;
45
46
 
46
47
  .text-display-lg,
47
48
  .text-display-md,
@@ -16,13 +16,13 @@ import {
16
16
  generateVariableSpacing,
17
17
  } from './constants';
18
18
  import { THEMED_CUSTOM_COLORS } from './constants/customColors.constants';
19
- import { firefoxPlugin, namesVariant } from './plugins';
19
+ import { firefoxPlugin, namesVariant, tableRowVariants } from './plugins';
20
20
  import { NAMES_GRADIENTS } from './constants/gradients.constants';
21
21
 
22
22
  export const BASE_CONFIG: Config = {
23
23
  content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
24
24
  darkMode: 'selector',
25
- plugins: [firefoxPlugin, namesVariant],
25
+ plugins: [firefoxPlugin, namesVariant, tableRowVariants],
26
26
  theme: {
27
27
  extend: {
28
28
  fontSize: {