@robr0/design-system 0.9.0 → 0.11.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 (99) hide show
  1. package/README.md +16 -10
  2. package/components/AgentPlan/AgentPlan.css +14 -3
  3. package/components/AiButton/AiButton.css +1 -1
  4. package/components/Alert/Alert.css +9 -0
  5. package/components/AlertDialog/AlertDialog.css +3 -1
  6. package/components/AlertDialog/AlertDialog.js +2 -3
  7. package/components/AppSidebar/AppSidebar.css +22 -3
  8. package/components/AppSidebar/AppSidebar.d.ts +6 -2
  9. package/components/AppSidebar/AppSidebar.js +67 -36
  10. package/components/Card/Card.css +7 -2
  11. package/components/CardStack/CardStack.css +106 -0
  12. package/components/CardStack/CardStack.d.ts +44 -0
  13. package/components/CardStack/CardStack.js +140 -0
  14. package/components/Carousel/Carousel.css +9 -0
  15. package/components/Carousel/Carousel.js +37 -4
  16. package/components/Chart/AreaChart.js +6 -5
  17. package/components/Chart/BarChart.js +1 -1
  18. package/components/Chart/LineChart.js +4 -3
  19. package/components/Chart/PieChart.js +2 -15
  20. package/components/Chart/RadarChart.js +5 -4
  21. package/components/Chart/RadialChart.js +2 -15
  22. package/components/Chart/ScatterChart.js +3 -13
  23. package/components/Chart/StackedBarChart.js +2 -15
  24. package/components/Chart/Treemap.js +3 -17
  25. package/components/Chart/palette.d.ts +1 -0
  26. package/components/Chart/palette.js +19 -0
  27. package/components/ChatMarker/ChatMarker.css +9 -0
  28. package/components/ChatMessage/ChatMessage.css +16 -0
  29. package/components/ChatThread/ChatThread.js +2 -2
  30. package/components/Chip/Chip.css +9 -0
  31. package/components/CodeBlock/CodeBlock.css +10 -1
  32. package/components/CodeBlock/CodeBlock.js +2 -1
  33. package/components/ColorPicker/ColorPicker.js +10 -0
  34. package/components/Combobox/Combobox.css +9 -0
  35. package/components/CommandPalette/CommandPalette.css +9 -0
  36. package/components/CommandPalette/CommandPalette.js +31 -5
  37. package/components/Composer/Composer.css +118 -0
  38. package/components/Composer/Composer.d.ts +7 -0
  39. package/components/Composer/Composer.js +7 -1
  40. package/components/ContactCard/ContactCard.css +9 -0
  41. package/components/ContextMenu/ContextMenu.js +39 -3
  42. package/components/DatePicker/DatePicker.js +16 -0
  43. package/components/Dialog/Dialog.css +3 -1
  44. package/components/Dialog/Dialog.js +5 -4
  45. package/components/DocumentChip/DocumentChip.css +9 -0
  46. package/components/Dropdown/Dropdown.js +22 -3
  47. package/components/DropdownMenu/DropdownMenu.js +43 -6
  48. package/components/EmptyState/EmptyState.css +9 -0
  49. package/components/FileInput/FileInput.css +9 -0
  50. package/components/Globe/Globe.css +155 -0
  51. package/components/Globe/Globe.d.ts +96 -0
  52. package/components/Globe/Globe.js +415 -0
  53. package/components/InterruptCard/InterruptCard.css +27 -17
  54. package/components/InterruptCard/InterruptCard.d.ts +1 -1
  55. package/components/InterruptCard/InterruptCard.js +1 -1
  56. package/components/MapCallout/MapCallout.css +46 -0
  57. package/components/MapCallout/MapCallout.d.ts +29 -0
  58. package/components/MapCallout/MapCallout.js +17 -0
  59. package/components/MapLegend/MapLegend.css +88 -0
  60. package/components/MapLegend/MapLegend.d.ts +39 -0
  61. package/components/MapLegend/MapLegend.js +68 -0
  62. package/components/MessageActions/MessageActions.css +9 -0
  63. package/components/MessageCard/MessageCard.css +68 -16
  64. package/components/MessageCard/MessageCard.d.ts +1 -1
  65. package/components/MessageCard/MessageCard.js +6 -4
  66. package/components/ModelPicker/ModelPicker.css +4 -1
  67. package/components/ModelPicker/ModelPicker.js +43 -1
  68. package/components/NotificationCenter/NotificationCenter.css +9 -0
  69. package/components/NotificationCenter/NotificationCenter.js +26 -10
  70. package/components/NumberInput/NumberInput.css +9 -0
  71. package/components/Popover/Popover.css +3 -1
  72. package/components/Popover/Popover.js +2 -1
  73. package/components/SectionTitle/SectionTitle.css +8 -0
  74. package/components/SectionTitle/SectionTitle.d.ts +3 -1
  75. package/components/SectionTitle/SectionTitle.js +6 -1
  76. package/components/SourceChip/SourceChip.css +9 -0
  77. package/components/Stat/Stat.css +9 -0
  78. package/components/Stepper/Stepper.css +9 -0
  79. package/components/TagInput/TagInput.css +9 -0
  80. package/components/TimePicker/TimePicker.js +4 -1
  81. package/components/Toast/Toast.css +32 -11
  82. package/components/Toast/Toast.js +5 -4
  83. package/components/ToggleGroup/ToggleGroup.css +9 -0
  84. package/components/ToolCall/ToolCall.css +18 -6
  85. package/components/Tooltip/Tooltip.js +20 -16
  86. package/components/registry.json +37 -0
  87. package/components/registry.json.d.ts +37 -0
  88. package/components/registry.json.js +2 -2
  89. package/index.d.ts +4 -0
  90. package/index.js +8 -0
  91. package/package.json +5 -1
  92. package/tokens/motion.d.ts +23 -0
  93. package/tokens/motion.js +14 -0
  94. package/tokens/registry.json +17 -0
  95. package/tokens/registry.json.d.ts +17 -0
  96. package/tokens/registry.json.js +1 -1
  97. package/tokens/tokens-dark.css +28 -13
  98. package/tokens/tokens-light.css +26 -12
  99. package/tokens/tokens-typography.css +20 -0
@@ -0,0 +1,88 @@
1
+ /* ============================================
2
+ MAP LEGEND COMPONENT
3
+ The corner block of a map: name, one line on
4
+ what it shows, and the key to its markers.
5
+ ============================================ */
6
+
7
+ /* Base */
8
+
9
+ .ds-map-legend {
10
+ display: inline-flex;
11
+ flex-direction: column;
12
+ gap: var(--gap-xs);
13
+ max-width: 32ch;
14
+ font-family: var(--font-family-code);
15
+ color: var(--color-text-primary);
16
+ }
17
+
18
+ /* Parts */
19
+
20
+ .ds-map-legend__title {
21
+ font-family: var(--font-family-primary);
22
+ font-size: var(--font-title-body-size);
23
+ font-weight: var(--font-title-body-weight);
24
+ line-height: var(--font-title-body-line-height);
25
+ letter-spacing: var(--font-title-body-letter-spacing);
26
+ color: var(--color-text-primary);
27
+ }
28
+
29
+ .ds-map-legend__description {
30
+ font-size: var(--font-caption-size);
31
+ font-weight: var(--font-caption-weight);
32
+ line-height: var(--font-caption-line-height);
33
+ color: var(--color-text-secondary);
34
+ margin: 0;
35
+ }
36
+
37
+ /* Key */
38
+
39
+ .ds-map-legend__key {
40
+ display: flex;
41
+ flex-direction: column;
42
+ gap: var(--gap-xxs);
43
+ margin: 0;
44
+ margin-top: var(--gap-xs);
45
+ }
46
+
47
+ .ds-map-legend__row {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: var(--gap-sm);
51
+ }
52
+
53
+ .ds-map-legend__glyph {
54
+ display: inline-flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ flex-shrink: 0;
58
+ color: var(--color-text-secondary);
59
+ }
60
+
61
+ .ds-map-legend__glyph svg {
62
+ display: block;
63
+ overflow: visible;
64
+ }
65
+
66
+ .ds-map-legend__glyph path,
67
+ .ds-map-legend__glyph rect {
68
+ fill: none;
69
+ stroke: currentColor;
70
+ stroke-width: 1.5;
71
+ stroke-linecap: round;
72
+ }
73
+
74
+ .ds-map-legend__arc-stop-start {
75
+ stop-color: var(--color-chart-series-7);
76
+ }
77
+
78
+ .ds-map-legend__arc-stop-end {
79
+ stop-color: var(--color-chart-series-5);
80
+ }
81
+
82
+ .ds-map-legend__label {
83
+ font-size: var(--font-caption-size);
84
+ font-weight: var(--font-caption-weight);
85
+ line-height: var(--font-caption-line-height);
86
+ color: var(--color-text-secondary);
87
+ margin: 0;
88
+ }
@@ -0,0 +1,39 @@
1
+ import { default as React } from 'react';
2
+ /** One row of the key. */
3
+ export interface MapLegendItem {
4
+ /**
5
+ * The marker the row explains. `point` and `anchor` draw Globe's cross and
6
+ * square; `arc` draws a short stroke of the arc gradient; a `line` is a flat
7
+ * rule in `color`; any React node renders as given.
8
+ */
9
+ glyph: 'point' | 'anchor' | 'arc' | 'line' | React.ReactNode;
10
+ /** What the marker means. */
11
+ label: React.ReactNode;
12
+ /** Colour for the `line` glyph (and the `arc` glyph, replacing its gradient). Any CSS colour. */
13
+ color?: string;
14
+ }
15
+ /** Props owned by MapLegend itself — everything else falls through to the root `<div>`. */
16
+ type MapLegendOwnProps = {
17
+ /**
18
+ * The map's name, set above the key. Intentionally shadows the native
19
+ * `title` tooltip attribute, which MapLegend does not expose.
20
+ */
21
+ title?: string;
22
+ /** One or two lines saying what the map shows. */
23
+ description?: React.ReactNode;
24
+ /** The key itself: one row per marker kind. */
25
+ items?: MapLegendItem[];
26
+ /** Additional CSS classes */
27
+ className?: string;
28
+ };
29
+ export interface MapLegendProps extends MapLegendOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof MapLegendOwnProps | 'children'> {
30
+ }
31
+ /**
32
+ * MapLegend — the block in the corner of a map: its name, a line on what it
33
+ * shows, and the key to its markers. The built-in glyphs are drawn on the
34
+ * same geometry Globe draws its markers with, so the key never shows a shape
35
+ * the map does not. Pure markup, no hooks, so it renders from a Server
36
+ * Component; the key is a `<dl>`, glyph as term and meaning as description.
37
+ */
38
+ export declare const MapLegend: React.ForwardRefExoticComponent<MapLegendProps & React.RefAttributes<HTMLDivElement>>;
39
+ export {};
@@ -0,0 +1,68 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import "./MapLegend.css";
4
+ const GLYPH_SIZE = 12;
5
+ const half = GLYPH_SIZE / 2;
6
+ const mark = 3.5;
7
+ const builtInGlyph = (item, gradientId) => {
8
+ switch (item.glyph) {
9
+ case "point":
10
+ return /* @__PURE__ */ jsx("path", { d: `M${half - mark} ${half}H${half + mark}M${half} ${half - mark}V${half + mark}` });
11
+ case "anchor":
12
+ return /* @__PURE__ */ jsx("rect", { x: half - mark, y: half - mark, width: mark * 2, height: mark * 2 });
13
+ case "arc":
14
+ return /* @__PURE__ */ jsx(
15
+ "path",
16
+ {
17
+ d: `M1 ${GLYPH_SIZE - 2}Q${half} -${half} ${GLYPH_SIZE - 1} ${GLYPH_SIZE - 2}`,
18
+ style: { stroke: item.color ?? `url(#${gradientId})` }
19
+ }
20
+ );
21
+ case "line":
22
+ return /* @__PURE__ */ jsx(
23
+ "path",
24
+ {
25
+ d: `M1 ${half}H${GLYPH_SIZE - 1}`,
26
+ style: item.color ? { stroke: item.color } : void 0
27
+ }
28
+ );
29
+ default:
30
+ return null;
31
+ }
32
+ };
33
+ const isBuiltIn = (glyph) => glyph === "point" || glyph === "anchor" || glyph === "arc" || glyph === "line";
34
+ const MapLegend = React.forwardRef(
35
+ ({ title, description, items = [], className = "", ...rest }, ref) => {
36
+ const baseClass = "ds-map-legend";
37
+ const gradientId = React.useId();
38
+ const classes = [baseClass, className].filter(Boolean).join(" ");
39
+ return /* @__PURE__ */ jsxs("div", { ...rest, ref, className: classes, children: [
40
+ title && /* @__PURE__ */ jsx("span", { className: `${baseClass}__title`, children: title }),
41
+ description && /* @__PURE__ */ jsx("p", { className: `${baseClass}__description`, children: description }),
42
+ items.length > 0 && /* @__PURE__ */ jsx("dl", { className: `${baseClass}__key`, children: items.map((item, i) => /* @__PURE__ */ jsxs("div", { className: `${baseClass}__row`, children: [
43
+ /* @__PURE__ */ jsx("dt", { className: `${baseClass}__glyph`, "aria-hidden": "true", children: isBuiltIn(item.glyph) ? /* @__PURE__ */ jsxs(
44
+ "svg",
45
+ {
46
+ viewBox: `0 0 ${GLYPH_SIZE} ${GLYPH_SIZE}`,
47
+ width: GLYPH_SIZE,
48
+ height: GLYPH_SIZE,
49
+ xmlns: "http://www.w3.org/2000/svg",
50
+ focusable: "false",
51
+ children: [
52
+ item.glyph === "arc" && !item.color && /* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsxs("linearGradient", { id: `${gradientId}-${i}`, x1: "0", y1: "0", x2: "1", y2: "0", children: [
53
+ /* @__PURE__ */ jsx("stop", { offset: "0", className: `${baseClass}__arc-stop-start` }),
54
+ /* @__PURE__ */ jsx("stop", { offset: "1", className: `${baseClass}__arc-stop-end` })
55
+ ] }) }),
56
+ builtInGlyph(item, `${gradientId}-${i}`)
57
+ ]
58
+ }
59
+ ) : item.glyph }),
60
+ /* @__PURE__ */ jsx("dd", { className: `${baseClass}__label`, children: item.label })
61
+ ] }, i)) })
62
+ ] });
63
+ }
64
+ );
65
+ MapLegend.displayName = "MapLegend";
66
+ export {
67
+ MapLegend
68
+ };
@@ -74,3 +74,12 @@
74
74
  opacity: 0.4;
75
75
  cursor: not-allowed;
76
76
  }
77
+
78
+ /* Icons take the colour of the text around them. Explicit, not just
79
+ inherited by default: a consumer stylesheet that sets a colour on
80
+ .material-symbols-rounded itself (a common global default) would
81
+ otherwise override inheritance and detach the icon from the state
82
+ colours this component sets on its parents. */
83
+ .ds-message-actions .material-symbols-rounded {
84
+ color: inherit;
85
+ }
@@ -5,9 +5,10 @@
5
5
  and an actions footer.
6
6
 
7
7
  Not the navigation Card — this is content
8
- furniture inside a conversation, --radius-md
9
- like the other chat primitives, sized by the
10
- bubble or turn that holds it.
8
+ furniture inside a conversation, sized by the
9
+ bubble or turn that holds it. It shares Card's
10
+ cover geometry and the chat bubbles' 24px
11
+ shell, so cards read the same across surfaces.
11
12
  ============================================ */
12
13
 
13
14
  .ds-message-card {
@@ -17,18 +18,29 @@
17
18
  max-width: var(--ds-message-card-max-width, 100%);
18
19
  background-color: var(--color-bg-container-primary);
19
20
  border: var(--border-xs) solid var(--color-bg-container-border);
20
- border-radius: var(--radius-md);
21
+ border-radius: var(--radius-xl);
21
22
  overflow: hidden;
22
23
  }
23
24
 
24
25
  /* ============================================
25
26
  MEDIA
26
- Full bleed the card's overflow clip rounds
27
- the corners, so the slot carries no padding.
27
+ Inset, not full bleed: 12px of card around a
28
+ 12px-radius slot keeps the corners concentric
29
+ with the card's 24px shell — the exact
30
+ geometry of Card's case-study cover, and the
31
+ same rule the Composer follows.
28
32
  ============================================ */
29
33
 
30
34
  .ds-message-card__media {
31
35
  display: block;
36
+ margin: var(--padding-sm-md) var(--padding-sm-md) 0;
37
+ border-radius: var(--radius-md);
38
+ overflow: hidden;
39
+ /* The slot carries its own surface, so drawn media — a chart, an SVG —
40
+ reads as the same image-shaped panel a photo would. The page tone, not
41
+ the container ramp: lightest in light mode, darkest in dark, so the
42
+ slot recedes behind the drawing instead of tinting it. */
43
+ background-color: var(--color-bg-page-primary);
32
44
  }
33
45
 
34
46
  .ds-message-card__media img {
@@ -36,15 +48,46 @@
36
48
  width: 100%;
37
49
  }
38
50
 
51
+ /* A Chart in the slot sheds its own panel chrome — the card is already the
52
+ panel, and the slot's page tone is the brightest surface in light mode and
53
+ the darkest in dark, so the drawing keeps maximum contrast against its
54
+ ground instead of sitting on a second container tone. */
55
+ .ds-message-card__media .ds-chart {
56
+ background-color: transparent;
57
+ border: none;
58
+ border-radius: 0;
59
+ padding: var(--padding-sm) var(--padding-sm) 0 0;
60
+ }
61
+
39
62
  /* ============================================
40
63
  BODY
64
+ The vertical rhythm is deliberately even: the
65
+ media-to-body distance (--padding-md) equals
66
+ the body-to-actions distance (--padding-sm on
67
+ each side of the seam), so the copy sits
68
+ centred between the image and the CTA row.
41
69
  ============================================ */
42
70
 
43
71
  .ds-message-card__body {
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: var(--gap-sm);
75
+ padding: var(--padding-md) var(--padding-lg) var(--padding-sm);
76
+ }
77
+
78
+ /* Without an actions footer the body closes the card, so it takes the full
79
+ bottom padding back. */
80
+ .ds-message-card__body:last-child {
81
+ padding-bottom: var(--padding-md);
82
+ }
83
+
84
+ /* Title and meta form one unit, tucked closer together than the body's
85
+ stack gap so the meta reads as the title's subline. */
86
+ .ds-message-card__header {
44
87
  display: flex;
45
88
  flex-direction: column;
46
89
  gap: var(--gap-xs);
47
- padding: var(--padding-sm-md) var(--padding-md);
90
+ min-width: 0;
48
91
  }
49
92
 
50
93
  .ds-message-card__title-row {
@@ -67,11 +110,11 @@
67
110
 
68
111
  .ds-message-card__title {
69
112
  min-width: 0;
70
- font-family: var(--font-title-body-family);
71
- font-size: var(--font-title-body-size);
72
- font-weight: var(--font-title-body-weight);
73
- line-height: var(--font-title-body-line-height);
74
- letter-spacing: var(--font-title-body-letter-spacing);
113
+ font-family: var(--font-heading-3-family);
114
+ font-size: var(--font-heading-3-size);
115
+ font-weight: var(--font-heading-3-weight);
116
+ line-height: var(--font-heading-3-line-height);
117
+ letter-spacing: var(--font-heading-3-letter-spacing);
75
118
  color: var(--color-text-primary);
76
119
  }
77
120
 
@@ -98,14 +141,23 @@
98
141
  ACTIONS
99
142
  Mirrors ToolCall's actions footer exactly, so
100
143
  approvals on a tool call and link actions on
101
- a card read as the same row.
144
+ a card read as the same row. Whitespace alone
145
+ separates it from the body — no rule.
102
146
  ============================================ */
103
147
 
104
148
  .ds-message-card__actions {
105
149
  display: flex;
106
150
  align-items: center;
107
- justify-content: flex-end;
151
+ justify-content: flex-start;
108
152
  gap: var(--gap-sm);
109
- padding: var(--padding-sm-md) var(--padding-md);
110
- border-top: var(--border-xs) solid var(--color-divider);
153
+ padding: var(--padding-sm) var(--padding-lg) var(--padding-md);
154
+ }
155
+
156
+ /* Icons take the colour of the text around them. Explicit, not just
157
+ inherited by default: a consumer stylesheet that sets a colour on
158
+ .material-symbols-rounded itself (a common global default) would
159
+ otherwise override inheritance and detach the icon from the state
160
+ colours this component sets on its parents. */
161
+ .ds-message-card .material-symbols-rounded {
162
+ color: inherit;
111
163
  }
@@ -5,7 +5,7 @@ type MessageCardOwnProps = {
5
5
  title?: string;
6
6
  /** One or two lines under the title. */
7
7
  description?: string;
8
- /** Top media slot — an image or chart, rendered flush to the card edges (the card clips it). */
8
+ /** Top media slot — an image or chart, inset from the card edges with its own rounded corners. */
9
9
  media?: React.ReactNode;
10
10
  /** Small leading icon beside the title — a Material Symbol name, or any custom element. */
11
11
  icon?: string | React.ReactNode;
@@ -10,11 +10,13 @@ const MessageCard = React.forwardRef(
10
10
  return /* @__PURE__ */ jsxs("div", { ...rest, ref, className: classes, children: [
11
11
  media && /* @__PURE__ */ jsx("div", { className: `${baseClass}__media`, children: media }),
12
12
  hasBody && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__body`, children: [
13
- (title || icon) && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__title-row`, children: [
14
- icon && /* @__PURE__ */ jsx("span", { className: `${baseClass}__icon`, "aria-hidden": "true", children: typeof icon === "string" ? /* @__PURE__ */ jsx("span", { className: "material-symbols-rounded", children: icon }) : icon }),
15
- title && /* @__PURE__ */ jsx("span", { className: `${baseClass}__title`, children: title })
13
+ (title || icon || meta) && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__header`, children: [
14
+ (title || icon) && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__title-row`, children: [
15
+ icon && /* @__PURE__ */ jsx("span", { className: `${baseClass}__icon`, "aria-hidden": "true", children: typeof icon === "string" ? /* @__PURE__ */ jsx("span", { className: "material-symbols-rounded", children: icon }) : icon }),
16
+ title && /* @__PURE__ */ jsx("span", { className: `${baseClass}__title`, children: title })
17
+ ] }),
18
+ meta && /* @__PURE__ */ jsx("span", { className: `${baseClass}__meta`, children: meta })
16
19
  ] }),
17
- meta && /* @__PURE__ */ jsx("span", { className: `${baseClass}__meta`, children: meta }),
18
20
  description && /* @__PURE__ */ jsx("p", { className: `${baseClass}__description`, children: description }),
19
21
  children
20
22
  ] }),
@@ -22,7 +22,10 @@
22
22
  display: inline-flex;
23
23
  align-items: center;
24
24
  gap: var(--gap-xxs);
25
- padding: var(--padding-xxs) var(--padding-sm);
25
+ /* CircularButton's 40px, so the pill and the composer's round icon
26
+ buttons read as one row of equals — same height, same resting shape. */
27
+ height: 40px;
28
+ padding: 0 var(--padding-md);
26
29
  background-color: var(--color-action-passive-bg);
27
30
  border: none;
28
31
  border-radius: var(--radius-full);
@@ -98,17 +98,54 @@ const ModelPicker = React.forwardRef(
98
98
  });
99
99
  }
100
100
  break;
101
+ case "Home":
102
+ if (isOpen) {
103
+ e.preventDefault();
104
+ const first = models.findIndex((m) => !m.disabled);
105
+ if (first >= 0) setFocusedIndex(first);
106
+ }
107
+ break;
108
+ case "End":
109
+ if (isOpen) {
110
+ e.preventDefault();
111
+ let last = models.length - 1;
112
+ while (last >= 0 && models[last].disabled) last--;
113
+ if (last >= 0) setFocusedIndex(last);
114
+ }
115
+ break;
101
116
  case "Escape":
102
117
  setIsOpen(false);
103
118
  break;
104
119
  }
105
120
  };
121
+ const handleEffortKeyDown = (e, index) => {
122
+ let targetIndex = -1;
123
+ switch (e.key) {
124
+ case "ArrowRight":
125
+ case "ArrowDown":
126
+ e.preventDefault();
127
+ targetIndex = (index + 1) % effortOptions.length;
128
+ break;
129
+ case "ArrowLeft":
130
+ case "ArrowUp":
131
+ e.preventDefault();
132
+ targetIndex = (index - 1 + effortOptions.length) % effortOptions.length;
133
+ break;
134
+ }
135
+ if (targetIndex >= 0) {
136
+ selectEffort(effortOptions[targetIndex].value);
137
+ document.getElementById(`${id}-effort-option-${targetIndex}`)?.focus();
138
+ }
139
+ };
106
140
  useEffect(() => {
107
141
  if (isOpen && focusedIndex >= 0 && listRef.current) {
108
142
  const focusedEl = listRef.current.children[focusedIndex];
109
143
  focusedEl?.scrollIntoView({ block: "nearest" });
110
144
  }
111
145
  }, [focusedIndex, isOpen]);
146
+ const activeOptionId = isOpen && focusedIndex >= 0 && models[focusedIndex] ? `${id}-option-${focusedIndex}` : void 0;
147
+ const checkedEffortIndex = effortOptions.findIndex((o) => o.value === currentEffort);
148
+ const effortTabStop = checkedEffortIndex >= 0 ? checkedEffortIndex : 0;
112
149
  const classes = [
113
150
  baseClass,
114
151
  `${baseClass}--${placement}`,
@@ -126,6 +163,7 @@ const ModelPicker = React.forwardRef(
126
163
  "aria-expanded": isOpen,
127
164
  "aria-haspopup": "listbox",
128
165
  "aria-controls": isOpen ? listboxId : void 0,
166
+ "aria-activedescendant": activeOptionId,
129
167
  "aria-label": rest["aria-label"] ?? "Choose a model",
130
168
  onClick: () => {
131
169
  setIsOpen((prev) => !prev);
@@ -150,6 +188,7 @@ const ModelPicker = React.forwardRef(
150
188
  children: models.map((model, index) => /* @__PURE__ */ jsxs(
151
189
  "li",
152
190
  {
191
+ id: `${id}-option-${index}`,
153
192
  className: [
154
193
  `${baseClass}__option`,
155
194
  model.value === currentValue ? `${baseClass}__option--selected` : "",
@@ -190,17 +229,20 @@ const ModelPicker = React.forwardRef(
190
229
  className: `${baseClass}__effort-options`,
191
230
  role: "radiogroup",
192
231
  "aria-labelledby": `${id}-effort-label`,
193
- children: effortOptions.map((option) => /* @__PURE__ */ jsx(
232
+ children: effortOptions.map((option, index) => /* @__PURE__ */ jsx(
194
233
  "button",
195
234
  {
196
235
  type: "button",
236
+ id: `${id}-effort-option-${index}`,
197
237
  className: [
198
238
  `${baseClass}__effort-option`,
199
239
  option.value === currentEffort ? `${baseClass}__effort-option--selected` : ""
200
240
  ].filter(Boolean).join(" "),
201
241
  role: "radio",
202
242
  "aria-checked": option.value === currentEffort,
243
+ tabIndex: index === effortTabStop ? 0 : -1,
203
244
  onClick: () => selectEffort(option.value),
245
+ onKeyDown: (e) => handleEffortKeyDown(e, index),
204
246
  children: option.label
205
247
  },
206
248
  option.value
@@ -260,3 +260,12 @@
260
260
  white-space: nowrap;
261
261
  border: 0;
262
262
  }
263
+
264
+ /* Icons take the colour of the text around them. Explicit, not just
265
+ inherited by default: a consumer stylesheet that sets a colour on
266
+ .material-symbols-rounded itself (a common global default) would
267
+ otherwise override inheritance and detach the icon from the state
268
+ colours this component sets on its parents. */
269
+ .ds-notification-center .material-symbols-rounded {
270
+ color: inherit;
271
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
- import React, { useState } from "react";
3
+ import React, { useState, useId } from "react";
4
4
  import { EmptyState } from "../EmptyState/EmptyState.js";
5
5
  import "./NotificationCenter.css";
6
6
  import "../../fonts/material-symbols.css";
@@ -42,6 +42,11 @@ const NotificationCenter = React.forwardRef(
42
42
  const isTabControlled = activeTab !== void 0;
43
43
  const [uncontrolledTab, setUncontrolledTab] = useState(defaultTab ?? tabs?.[0]?.value);
44
44
  const currentTab = isTabControlled ? activeTab : uncontrolledTab;
45
+ const idBase = useId();
46
+ const panelId = `${idBase}-panel`;
47
+ const tabIdFor = (index) => `${idBase}-tab-${index}`;
48
+ const activeTabIndex = tabs?.findIndex((tab) => tab.value === currentTab) ?? -1;
49
+ const hasTabs = !!tabs && tabs.length > 0;
45
50
  const selectTab = (value) => {
46
51
  if (!isTabControlled) setUncontrolledTab(value);
47
52
  onTabChange?.(value);
@@ -80,21 +85,23 @@ const NotificationCenter = React.forwardRef(
80
85
  /* @__PURE__ */ jsxs("header", { className: `${baseClass}__header`, children: [
81
86
  /* @__PURE__ */ jsxs("div", { className: `${baseClass}__heading`, children: [
82
87
  /* @__PURE__ */ jsx("span", { className: `${baseClass}__title`, children: title }),
83
- unreadCount !== void 0 && unreadCount > 0 && /* @__PURE__ */ jsxs("span", { className: `${baseClass}__unread`, children: [
88
+ unreadCount !== void 0 && unreadCount > 0 && /* @__PURE__ */ jsxs("span", { className: `${baseClass}__unread`, "aria-live": "polite", children: [
84
89
  unreadCount,
85
90
  " unread"
86
91
  ] })
87
92
  ] }),
88
93
  onMarkAllRead && /* @__PURE__ */ jsx("button", { type: "button", className: `${baseClass}__mark-all`, onClick: onMarkAllRead, children: markAllLabel })
89
94
  ] }),
90
- tabs && tabs.length > 0 && /* @__PURE__ */ jsx("div", { className: `${baseClass}__tabs`, role: "tablist", "aria-label": `${title} filters`, children: tabs.map((tab, index) => {
95
+ hasTabs && /* @__PURE__ */ jsx("div", { className: `${baseClass}__tabs`, role: "tablist", "aria-label": `${title} filters`, children: tabs.map((tab, index) => {
91
96
  const isActive = tab.value === currentTab;
92
97
  return /* @__PURE__ */ jsxs(
93
98
  "button",
94
99
  {
95
100
  type: "button",
101
+ id: tabIdFor(index),
96
102
  role: "tab",
97
103
  "aria-selected": isActive,
104
+ "aria-controls": panelId,
98
105
  tabIndex: isActive ? 0 : -1,
99
106
  className: [
100
107
  `${baseClass}__tab`,
@@ -110,15 +117,24 @@ const NotificationCenter = React.forwardRef(
110
117
  tab.value
111
118
  );
112
119
  }) }),
113
- /* @__PURE__ */ jsx("div", { className: `${baseClass}__list`, children: hasItems ? children : emptyState ?? /* @__PURE__ */ jsx(
114
- EmptyState,
120
+ /* @__PURE__ */ jsx(
121
+ "div",
115
122
  {
116
- icon: "notifications",
117
- title: "Nothing new",
118
- description: "Notifications will collect here as they arrive.",
119
- size: "compact"
123
+ className: `${baseClass}__list`,
124
+ role: hasTabs ? "tabpanel" : void 0,
125
+ id: hasTabs ? panelId : void 0,
126
+ "aria-labelledby": hasTabs && activeTabIndex >= 0 ? tabIdFor(activeTabIndex) : void 0,
127
+ children: hasItems ? children : emptyState ?? /* @__PURE__ */ jsx(
128
+ EmptyState,
129
+ {
130
+ icon: "notifications",
131
+ title: "Nothing new",
132
+ description: "Notifications will collect here as they arrive.",
133
+ size: "compact"
134
+ }
135
+ )
120
136
  }
121
- ) })
137
+ )
122
138
  ] });
123
139
  }
124
140
  );
@@ -152,3 +152,12 @@
152
152
  .ds-numberinput--compact .ds-numberinput__stepper {
153
153
  padding: 0 var(--padding-sm); /* 8px */
154
154
  }
155
+
156
+ /* Icons take the colour of the text around them. Explicit, not just
157
+ inherited by default: a consumer stylesheet that sets a colour on
158
+ .material-symbols-rounded itself (a common global default) would
159
+ otherwise override inheritance and detach the icon from the state
160
+ colours this component sets on its parents. */
161
+ .ds-numberinput .material-symbols-rounded {
162
+ color: inherit;
163
+ }
@@ -35,13 +35,15 @@
35
35
 
36
36
  /* Hidden by default */
37
37
  opacity: 0;
38
+ visibility: hidden; /* keeps panel content out of the tab order when closed */
38
39
  pointer-events: none;
39
40
  transform: scale(0.96);
40
- transition: opacity var(--motion-duration-base) var(--motion-ease-standard), transform var(--motion-duration-base) var(--motion-ease-standard);
41
+ transition: opacity var(--motion-duration-base) var(--motion-ease-standard), transform var(--motion-duration-base) var(--motion-ease-standard), visibility var(--motion-duration-base) var(--motion-ease-standard);
41
42
  }
42
43
 
43
44
  .ds-popover__panel--open {
44
45
  opacity: 1;
46
+ visibility: visible;
45
47
  pointer-events: auto;
46
48
  transform: scale(1);
47
49
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
3
  import React, { useState, useRef, useCallback, useEffect } from "react";
4
+ import { MOTION_HOVER_HIDE_DELAY_MS } from "../../tokens/motion.js";
4
5
  import "./Popover.css";
5
6
  const INTERACTIVE_TAGS = /* @__PURE__ */ new Set(["button", "a", "input", "select", "textarea", "summary"]);
6
7
  const canCarryPopoverSemantics = (el) => typeof el.type === "string" ? INTERACTIVE_TAGS.has(el.type) : true;
@@ -59,7 +60,7 @@ const Popover = ({
59
60
  };
60
61
  const handleMouseLeave = () => {
61
62
  if (trigger === "hover") {
62
- hoverTimeoutRef.current = setTimeout(() => setOpen(false), 150);
63
+ hoverTimeoutRef.current = setTimeout(() => setOpen(false), MOTION_HOVER_HIDE_DELAY_MS);
63
64
  }
64
65
  };
65
66
  const baseClass = "ds-popover";
@@ -11,6 +11,14 @@
11
11
  padding-bottom: var(--padding-xl);
12
12
  }
13
13
 
14
+ /* Divider-less variant: for headings above content that draws its own
15
+ lines, where a second rule would double up. The generous clearance
16
+ the divider needed drops to a small breathing gap. */
17
+ .ds-section-title--no-divider {
18
+ border-bottom: none;
19
+ padding-bottom: var(--padding-md);
20
+ }
21
+
14
22
  /* ============================================
15
23
  HEADING
16
24
  ============================================ */
@@ -4,7 +4,9 @@ export interface SectionTitleProps {
4
4
  title: string;
5
5
  /** Optional trailing content (count, badge, metadata) */
6
6
  trailing?: React.ReactNode;
7
+ /** Whether to draw the bottom divider line. Set false above content that draws its own lines (bordered tables, calendars), so the section separates by whitespace alone. */
8
+ divider?: boolean;
7
9
  /** Additional CSS classes */
8
10
  className?: string;
9
11
  }
10
- export declare function SectionTitle({ title, trailing, className, }: SectionTitleProps): React.JSX.Element;
12
+ export declare function SectionTitle({ title, trailing, divider, className, }: SectionTitleProps): React.JSX.Element;