@robr0/design-system 0.14.0 → 0.16.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 (88) hide show
  1. package/README.md +11 -8
  2. package/behaviors/focusable.d.ts +26 -0
  3. package/behaviors/focusable.js +18 -0
  4. package/behaviors/useFocusScope.d.ts +38 -0
  5. package/behaviors/useFocusScope.js +82 -0
  6. package/behaviors/useLayer.d.ts +43 -0
  7. package/behaviors/useLayer.js +55 -0
  8. package/behaviors/useMounted.d.ts +7 -0
  9. package/behaviors/useMounted.js +14 -0
  10. package/behaviors/useScrollLock.d.ts +55 -0
  11. package/behaviors/useScrollLock.js +59 -0
  12. package/charts.d.ts +1 -0
  13. package/charts.js +2 -0
  14. package/components/AgentPlan/AgentPlan.css +12 -3
  15. package/components/AiButton/AiButton.css +327 -0
  16. package/components/AiButton/AiButton.d.ts +42 -0
  17. package/components/AiButton/AiButton.js +140 -16
  18. package/components/AlertDialog/AlertDialog.js +13 -58
  19. package/components/AnchorNav/AnchorNav.css +74 -0
  20. package/components/AnchorNav/AnchorNav.d.ts +7 -0
  21. package/components/AnchorNav/AnchorNav.js +74 -21
  22. package/components/Button/Button.css +54 -0
  23. package/components/Button/Button.d.ts +2 -2
  24. package/components/Chart/Chart.css +4 -1
  25. package/components/CircularButton/CircularButton.css +46 -0
  26. package/components/CircularButton/CircularButton.d.ts +13 -2
  27. package/components/CircularButton/CircularButton.js +19 -16
  28. package/components/Combobox/Combobox.js +2 -1
  29. package/components/CommandPalette/CommandPalette.css +36 -8
  30. package/components/CommandPalette/CommandPalette.js +9 -54
  31. package/components/Composer/Composer.css +72 -0
  32. package/components/Composer/Composer.d.ts +25 -6
  33. package/components/Composer/Composer.js +8 -0
  34. package/components/ContributionGraph/ContributionGraph.css +21 -0
  35. package/components/ContributionGraph/ContributionGraph.js +18 -10
  36. package/components/Dialog/Dialog.js +9 -62
  37. package/components/Drawer/Drawer.js +9 -62
  38. package/components/Dropdown/Dropdown.js +2 -1
  39. package/components/DropdownMenu/DropdownMenu.js +11 -3
  40. package/components/Figure/Figure.css +14 -4
  41. package/components/Figure/Figure.js +11 -20
  42. package/components/FunnelChart/FunnelChart.css +11 -36
  43. package/components/FunnelChart/FunnelChart.d.ts +19 -16
  44. package/components/FunnelChart/FunnelChart.js +67 -39
  45. package/components/Gauge/Gauge.css +11 -1
  46. package/components/Meter/Meter.css +10 -1
  47. package/components/ModelPicker/ModelPicker.js +2 -1
  48. package/components/Popover/Popover.js +3 -0
  49. package/components/ProgressBar/ProgressBar.css +1 -1
  50. package/components/SegmentedControl/SegmentedControl.css +27 -2
  51. package/components/SegmentedControl/SegmentedControl.d.ts +3 -1
  52. package/components/SegmentedControl/SegmentedControl.js +3 -1
  53. package/components/Slider/Slider.css +1 -1
  54. package/components/Slider/Slider.js +1 -1
  55. package/components/SourceTrail/SourceTrail.css +209 -0
  56. package/components/SourceTrail/SourceTrail.d.ts +49 -0
  57. package/components/SourceTrail/SourceTrail.js +108 -0
  58. package/components/Sparkline/Sparkline.css +26 -2
  59. package/components/Sparkline/Sparkline.js +2 -1
  60. package/components/Spinner/Spinner.css +1 -1
  61. package/components/SplitButton/SplitButton.d.ts +1 -1
  62. package/components/SplitButton/SplitButton.js +1 -0
  63. package/components/TimePicker/TimePicker.js +2 -1
  64. package/components/ToggleGroup/ToggleGroup.css +17 -0
  65. package/components/ToggleGroup/ToggleGroup.d.ts +2 -0
  66. package/components/ToggleGroup/ToggleGroup.js +2 -0
  67. package/components/ToolCall/ToolCall.css +16 -3
  68. package/components/Tooltip/Tooltip.css +24 -7
  69. package/components/Tooltip/Tooltip.d.ts +2 -1
  70. package/components/Tooltip/Tooltip.js +6 -0
  71. package/components/UsageCard/UsageCard.css +30 -0
  72. package/components/UsageCard/UsageCard.d.ts +48 -0
  73. package/components/UsageCard/UsageCard.js +54 -0
  74. package/components/registry.d.ts +8 -2
  75. package/components/registry.json +43 -16
  76. package/components/registry.json.d.ts +43 -16
  77. package/components/registry.json.js +1 -1
  78. package/index.d.ts +2 -1
  79. package/index.js +4 -2
  80. package/package.json +5 -1
  81. package/tokens/motion.d.ts +10 -4
  82. package/tokens/motion.js +7 -1
  83. package/tokens/registry.json +10 -0
  84. package/tokens/registry.json.d.ts +10 -0
  85. package/tokens/registry.json.js +1 -1
  86. package/tokens/tokens-dark.css +22 -2
  87. package/tokens/tokens-light.css +38 -11
  88. package/tokens/tokens-primitives.css +3 -0
@@ -1,13 +1,14 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
- import { useState, useRef, useId, useSyncExternalStore, useCallback, useMemo, useEffect } from "react";
3
+ import { useState, useRef, useId, useCallback, useMemo, useEffect } from "react";
4
4
  import ReactDOM from "react-dom";
5
5
  import { Kbd } from "../Kbd/Kbd.js";
6
6
  import "./CommandPalette.css";
7
7
  import "../../fonts/material-symbols.css";
8
- const emptySubscribe = () => () => {
9
- };
10
- const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
8
+ import { useMounted } from "../../behaviors/useMounted.js";
9
+ import { useLayer } from "../../behaviors/useLayer.js";
10
+ import { useFocusScope } from "../../behaviors/useFocusScope.js";
11
+ import { useScrollLock } from "../../behaviors/useScrollLock.js";
11
12
  const CommandPalette = ({
12
13
  open,
13
14
  onOpenChange,
@@ -28,10 +29,9 @@ const CommandPalette = ({
28
29
  const inputRef = useRef(null);
29
30
  const panelRef = useRef(null);
30
31
  const listRef = useRef(null);
31
- const previousFocusRef = useRef(null);
32
32
  const generatedId = useId();
33
33
  const baseClass = "ds-command-palette";
34
- const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false);
34
+ const mounted = useMounted();
35
35
  const matches = useCallback(
36
36
  (command) => {
37
37
  const q = query.trim().toLowerCase();
@@ -68,54 +68,9 @@ const CommandPalette = ({
68
68
  document.addEventListener("keydown", handleHotkey);
69
69
  return () => document.removeEventListener("keydown", handleHotkey);
70
70
  }, [hotkey, open, onOpenChange]);
71
- useEffect(() => {
72
- if (open) {
73
- previousFocusRef.current = document.activeElement;
74
- const raf = requestAnimationFrame(() => inputRef.current?.focus());
75
- return () => cancelAnimationFrame(raf);
76
- }
77
- if (previousFocusRef.current) {
78
- previousFocusRef.current.focus();
79
- previousFocusRef.current = null;
80
- }
81
- }, [open]);
82
- useEffect(() => {
83
- if (open) {
84
- document.body.style.overflow = "hidden";
85
- } else {
86
- document.body.style.overflow = "";
87
- }
88
- return () => {
89
- document.body.style.overflow = "";
90
- };
91
- }, [open]);
92
- useEffect(() => {
93
- if (!open) return;
94
- const handleTrapKeyDown = (e) => {
95
- if (e.key === "Escape") {
96
- e.preventDefault();
97
- onOpenChange(false);
98
- return;
99
- }
100
- if (e.key === "Tab") {
101
- const panel = panelRef.current;
102
- if (!panel) return;
103
- const focusable = panel.querySelectorAll(FOCUSABLE);
104
- if (focusable.length === 0) return;
105
- const first = focusable[0];
106
- const last = focusable[focusable.length - 1];
107
- if (e.shiftKey && document.activeElement === first) {
108
- e.preventDefault();
109
- last.focus();
110
- } else if (!e.shiftKey && document.activeElement === last) {
111
- e.preventDefault();
112
- first.focus();
113
- }
114
- }
115
- };
116
- document.addEventListener("keydown", handleTrapKeyDown);
117
- return () => document.removeEventListener("keydown", handleTrapKeyDown);
118
- }, [open, onOpenChange]);
71
+ useLayer({ open, onDismiss: () => onOpenChange(false) });
72
+ useFocusScope(panelRef, { active: open, initialFocus: () => inputRef.current });
73
+ useScrollLock(open, { strategy: "cancel-events", allowWithin: listRef });
119
74
  const runCommand = (command) => {
120
75
  if (command.disabled) return;
121
76
  command.onSelect?.();
@@ -140,6 +140,24 @@
140
140
  }
141
141
  }
142
142
 
143
+ /* Working: while a response streams, the ring stays lit and keeps turning
144
+ whether or not the field holds focus — the visible half of the `streaming`
145
+ contract for glowing composers. The pairing is defined in design.md's
146
+ Composer section; streaming without aiGlow changes nothing. */
147
+ .ds-composer--ai-glow.ds-composer--streaming {
148
+ border-color: transparent;
149
+ }
150
+
151
+ .ds-composer--ai-glow.ds-composer--streaming::before {
152
+ opacity: 1;
153
+ animation-play-state: running;
154
+ }
155
+
156
+ .ds-composer--ai-glow.ds-composer--streaming::after {
157
+ opacity: 0.3;
158
+ animation-play-state: running;
159
+ }
160
+
143
161
  /* Reduced motion: the ring and glow stay, the turning stops. */
144
162
  @media (prefers-reduced-motion: reduce) {
145
163
  .ds-composer--ai-glow::before,
@@ -148,6 +166,60 @@
148
166
  }
149
167
  }
150
168
 
169
+ /* ============================================
170
+ CONTEXT CHIP
171
+ A full-width, non-interactive note at the very top of the shell — what
172
+ the model is currently looking at. Composer owns the chrome; the caller
173
+ passes the text.
174
+ ============================================ */
175
+
176
+ .ds-composer__context {
177
+ box-sizing: border-box;
178
+ display: flex;
179
+ align-items: center;
180
+ gap: var(--gap-xs);
181
+ /* The action bar's tight --padding-sm ring, open toward the text below */
182
+ margin: var(--padding-sm) var(--padding-sm) 0;
183
+ /* ModelPicker's trigger geometry — the send button's height with the same
184
+ 0/padding-md inset — so the chip and the bar's pills read as one family */
185
+ height: var(--ds-composer-send-size);
186
+ padding: 0 var(--padding-md);
187
+ background-color: var(--color-chat-context-bg);
188
+ /* A full pill, matching the suggestion chips around the composer */
189
+ border-radius: var(--radius-full);
190
+ font-family: var(--font-paragraph-sm-family);
191
+ font-size: var(--font-paragraph-sm-size);
192
+ font-weight: var(--font-paragraph-sm-weight);
193
+ line-height: var(--font-paragraph-sm-line-height);
194
+ letter-spacing: var(--font-paragraph-sm-letter-spacing);
195
+ /* Tertiary, like DocumentChip's metadata line — the note is ambient
196
+ context, quieter than anything a person typed. The icon inherits it:
197
+ one tone for the whole note. */
198
+ color: var(--color-text-tertiary);
199
+ /* Not editable, not a control — but a click still falls through the shell
200
+ handler and focuses the textarea */
201
+ cursor: default;
202
+ }
203
+
204
+ .ds-composer__context-icon {
205
+ display: inline-flex;
206
+ align-items: center;
207
+ flex-shrink: 0;
208
+ }
209
+
210
+ .ds-composer__context-icon .material-symbols-rounded {
211
+ --icon-size: var(--icon-size-sm);
212
+ }
213
+
214
+ /* The text owns the truncation: one line, ellipsized only when the note
215
+ actually outgrows the shell — an ellipsis is a truncation mark, not
216
+ punctuation. The icon beside it never shrinks. */
217
+ .ds-composer__context-text {
218
+ overflow: hidden;
219
+ text-overflow: ellipsis;
220
+ white-space: nowrap;
221
+ }
222
+
151
223
  /* ============================================
152
224
  ATTACHMENTS ROW
153
225
  ============================================ */
@@ -21,7 +21,8 @@ type ComposerOwnProps = {
21
21
  onSubmit?: (value: string) => void;
22
22
  /**
23
23
  * A response is streaming: the send button becomes a stop button, submit
24
- * is blocked, and Enter is inert.
24
+ * is blocked, and Enter is inert. On a glowing composer (`aiGlow`) the
25
+ * gradient ring also stays lit and keeps turning while this is true.
25
26
  */
26
27
  streaming?: boolean;
27
28
  /** Fires when the stop button is pressed while `streaming`. */
@@ -32,9 +33,26 @@ type ComposerOwnProps = {
32
33
  * While focused, the shell wears AiButton's slowly rotating gradient ring
33
34
  * and glow in place of the plain selected border — the system's "a model
34
35
  * answers here" signal, for composers whose messages are answered by one.
35
- * Off by default.
36
+ * While `streaming`, the ring stays lit and turning whether or not the
37
+ * field holds focus. Off by default.
36
38
  */
37
39
  aiGlow?: boolean;
40
+ /**
41
+ * Contextual note rendered as a full-width, non-interactive chip at the
42
+ * very top of the shell, above any attachments — the "what the model is
43
+ * looking at" line a chat host pins over the message ("Looking at
44
+ * “Page name”"). One line: a note too long for the shell truncates with
45
+ * an ellipsis. Composer owns the chip's chrome; the caller passes the
46
+ * text.
47
+ */
48
+ context?: React.ReactNode;
49
+ /**
50
+ * Material Symbol name rendered at the left of the context chip
51
+ * (`visibility`, `article`…). Decorative and hidden from assistive
52
+ * technology — the chip's text carries the meaning. None by default,
53
+ * matching the ai set's icon-free-unless-asked convention.
54
+ */
55
+ contextIcon?: string;
38
56
  /**
39
57
  * Attachment row rendered above the textarea (DocumentChips). Fully
40
58
  * controlled by the caller — Composer never owns the list.
@@ -57,10 +75,11 @@ type ComposerOwnProps = {
57
75
  export interface ComposerProps extends ComposerOwnProps, Omit<React.ComponentPropsWithoutRef<'textarea'>, keyof ComposerOwnProps> {
58
76
  }
59
77
  /**
60
- * Composer is the chat input shell: an attachments row, an auto-growing
61
- * textarea, a leading actions slot, and a trailing send button — the one
62
- * sanctioned primary-action teal in the chat set, because sending a message
63
- * is a genuine primary CTA. While `streaming`, send becomes stop and Enter
78
+ * Composer is the chat input shell: an optional context note ("Looking at
79
+ * “Page name”"), an attachments row, an auto-growing textarea, a leading
80
+ * actions slot, and a trailing send button the one sanctioned
81
+ * primary-action teal in the chat set, because sending a message is a
82
+ * genuine primary CTA. While `streaming`, send becomes stop and Enter
64
83
  * is inert.
65
84
  *
66
85
  * The textarea grows with its content up to `maxRows`, then scrolls
@@ -2,6 +2,7 @@
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
3
  import React, { useRef, useState, useLayoutEffect } from "react";
4
4
  import { CircularButton } from "../CircularButton/CircularButton.js";
5
+ import "../../fonts/material-symbols.css";
5
6
  import "./Composer.css";
6
7
  const Composer = React.forwardRef(
7
8
  ({
@@ -13,6 +14,8 @@ const Composer = React.forwardRef(
13
14
  onStop,
14
15
  maxRows = 8,
15
16
  aiGlow = false,
17
+ context,
18
+ contextIcon,
16
19
  attachments,
17
20
  actions,
18
21
  trailingActions,
@@ -77,6 +80,7 @@ const Composer = React.forwardRef(
77
80
  const classes = [
78
81
  baseClass,
79
82
  aiGlow ? `${baseClass}--ai-glow` : "",
83
+ streaming ? `${baseClass}--streaming` : "",
80
84
  disabled ? `${baseClass}--disabled` : "",
81
85
  className
82
86
  ].filter(Boolean).join(" ");
@@ -88,6 +92,10 @@ const Composer = React.forwardRef(
88
92
  style: { "--ds-composer-max-rows": maxRows },
89
93
  onClick: handleShellClick,
90
94
  children: [
95
+ context && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__context`, children: [
96
+ contextIcon && /* @__PURE__ */ jsx("span", { className: `${baseClass}__context-icon`, "aria-hidden": "true", children: /* @__PURE__ */ jsx("span", { className: "material-symbols-rounded", children: contextIcon }) }),
97
+ /* @__PURE__ */ jsx("span", { className: `${baseClass}__context-text`, children: context })
98
+ ] }),
91
99
  attachments && /* @__PURE__ */ jsx("div", { className: `${baseClass}__attachments`, children: attachments }),
92
100
  /* @__PURE__ */ jsx("div", { className: `${baseClass}__content`, ref: contentRef, children: /* @__PURE__ */ jsx(
93
101
  "textarea",
@@ -79,6 +79,27 @@
79
79
  width: 100%;
80
80
  height: auto;
81
81
  aspect-ratio: 1 / 1;
82
+ /* The year sweeps in left to right on mount: each column's delay steps by
83
+ a fraction of the entrance duration (--ds-contribution-week, set inline
84
+ per column). Token-composed, so reduced motion collapses the sweep. */
85
+ animation: ds-contribution-fade var(--motion-duration-slow) var(--motion-ease-standard)
86
+ backwards;
87
+ animation-delay: calc(
88
+ var(--ds-contribution-week, 0) * var(--motion-duration-slower) / 40
89
+ );
90
+ }
91
+
92
+ @keyframes ds-contribution-fade {
93
+ from {
94
+ opacity: 0;
95
+ }
96
+ }
97
+
98
+ /* Hover lifts the day under the pointer, so the title tooltip has a visible
99
+ anchor. */
100
+ .ds-contribution-graph__week .ds-contribution-graph__cell:not(.ds-contribution-graph__cell--empty):hover {
101
+ outline: var(--border-xs) solid var(--color-text-tertiary);
102
+ outline-offset: calc(-1 * var(--border-xs));
82
103
  }
83
104
 
84
105
  .ds-contribution-graph__cell--empty {
@@ -112,16 +112,24 @@ const ContributionGraph = ({
112
112
  style: { gridTemplateColumns: columnTemplate },
113
113
  role: "img",
114
114
  "aria-label": caption ?? "Contribution activity",
115
- children: weeks.map((week, weekIndex) => /* @__PURE__ */ jsx("div", { className: "ds-contribution-graph__week", children: week.map(
116
- (day, dayIndex) => day ? /* @__PURE__ */ jsx(
117
- "div",
118
- {
119
- className: `ds-contribution-graph__cell ds-contribution-graph__cell--level-${day.level}`,
120
- title: formatTooltip(day)
121
- },
122
- day.date
123
- ) : /* @__PURE__ */ jsx("div", { className: "ds-contribution-graph__cell ds-contribution-graph__cell--empty" }, `pad-${dayIndex}`)
124
- ) }, weekIndex))
115
+ children: weeks.map((week, weekIndex) => /* @__PURE__ */ jsx(
116
+ "div",
117
+ {
118
+ className: "ds-contribution-graph__week",
119
+ style: { "--ds-contribution-week": weekIndex },
120
+ children: week.map(
121
+ (day, dayIndex) => day ? /* @__PURE__ */ jsx(
122
+ "div",
123
+ {
124
+ className: `ds-contribution-graph__cell ds-contribution-graph__cell--level-${day.level}`,
125
+ title: formatTooltip(day)
126
+ },
127
+ day.date
128
+ ) : /* @__PURE__ */ jsx("div", { className: "ds-contribution-graph__cell ds-contribution-graph__cell--empty" }, `pad-${dayIndex}`)
129
+ )
130
+ },
131
+ weekIndex
132
+ ))
125
133
  }
126
134
  )
127
135
  ] }),
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
- import React, { useRef, useId, useSyncExternalStore, useEffect } from "react";
3
+ import React, { useRef, useId } from "react";
4
4
  import ReactDOM from "react-dom";
5
5
  import "./Dialog.css";
6
6
  import "../../fonts/material-symbols.css";
7
- const emptySubscribe = () => () => {
8
- };
9
- const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
+ import { useMounted } from "../../behaviors/useMounted.js";
8
+ import { useLayer } from "../../behaviors/useLayer.js";
9
+ import { useFocusScope } from "../../behaviors/useFocusScope.js";
10
+ import { useScrollLock } from "../../behaviors/useScrollLock.js";
10
11
  const Dialog = React.forwardRef(
11
12
  ({
12
13
  open,
@@ -21,7 +22,6 @@ const Dialog = React.forwardRef(
21
22
  ...rest
22
23
  }, ref) => {
23
24
  const panelRef = useRef(null);
24
- const previousFocusRef = useRef(null);
25
25
  const titleId = useId();
26
26
  const descId = useId();
27
27
  const baseClass = "ds-dialog";
@@ -30,63 +30,10 @@ const Dialog = React.forwardRef(
30
30
  if (typeof ref === "function") ref(node);
31
31
  else if (ref) ref.current = node;
32
32
  };
33
- const mounted = useSyncExternalStore(
34
- emptySubscribe,
35
- () => true,
36
- () => false
37
- );
38
- useEffect(() => {
39
- if (open) {
40
- previousFocusRef.current = document.activeElement;
41
- }
42
- }, [open]);
43
- useEffect(() => {
44
- if (!open) return;
45
- const panel = panelRef.current;
46
- if (panel) {
47
- const first = panel.querySelector(
48
- "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])"
49
- ) ?? panel.querySelector(FOCUSABLE);
50
- (first ?? panel).focus();
51
- }
52
- const handleKeyDown = (e) => {
53
- if (e.key === "Escape" && dismissible) {
54
- onOpenChange(false);
55
- return;
56
- }
57
- if (e.key === "Tab" && panel) {
58
- const focusable = panel.querySelectorAll(FOCUSABLE);
59
- if (focusable.length === 0) return;
60
- const first = focusable[0];
61
- const last = focusable[focusable.length - 1];
62
- if (e.shiftKey && document.activeElement === first) {
63
- e.preventDefault();
64
- last.focus();
65
- } else if (!e.shiftKey && document.activeElement === last) {
66
- e.preventDefault();
67
- first.focus();
68
- }
69
- }
70
- };
71
- document.addEventListener("keydown", handleKeyDown);
72
- return () => document.removeEventListener("keydown", handleKeyDown);
73
- }, [open, dismissible, onOpenChange]);
74
- useEffect(() => {
75
- if (!open && previousFocusRef.current) {
76
- previousFocusRef.current.focus();
77
- previousFocusRef.current = null;
78
- }
79
- }, [open]);
80
- useEffect(() => {
81
- if (open) {
82
- document.body.style.overflow = "hidden";
83
- } else {
84
- document.body.style.overflow = "";
85
- }
86
- return () => {
87
- document.body.style.overflow = "";
88
- };
89
- }, [open]);
33
+ const mounted = useMounted();
34
+ useLayer({ open, dismissOnEscape: dismissible, onDismiss: () => onOpenChange(false) });
35
+ useFocusScope(panelRef, { active: open });
36
+ useScrollLock(open);
90
37
  const handleBackdropClick = () => {
91
38
  if (dismissible) onOpenChange(false);
92
39
  };
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
- import React, { useRef, useId, useSyncExternalStore, useEffect } from "react";
3
+ import React, { useRef, useId } from "react";
4
4
  import ReactDOM from "react-dom";
5
5
  import "./Drawer.css";
6
6
  import "../../fonts/material-symbols.css";
7
- const emptySubscribe = () => () => {
8
- };
9
- const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
+ import { useMounted } from "../../behaviors/useMounted.js";
8
+ import { useLayer } from "../../behaviors/useLayer.js";
9
+ import { useFocusScope } from "../../behaviors/useFocusScope.js";
10
+ import { useScrollLock } from "../../behaviors/useScrollLock.js";
10
11
  const Drawer = React.forwardRef(
11
12
  ({
12
13
  open,
@@ -22,7 +23,6 @@ const Drawer = React.forwardRef(
22
23
  ...rest
23
24
  }, ref) => {
24
25
  const panelRef = useRef(null);
25
- const previousFocusRef = useRef(null);
26
26
  const titleId = useId();
27
27
  const descId = useId();
28
28
  const baseClass = "ds-drawer";
@@ -31,63 +31,10 @@ const Drawer = React.forwardRef(
31
31
  if (typeof ref === "function") ref(node);
32
32
  else if (ref) ref.current = node;
33
33
  };
34
- const mounted = useSyncExternalStore(
35
- emptySubscribe,
36
- () => true,
37
- () => false
38
- );
39
- useEffect(() => {
40
- if (open) {
41
- previousFocusRef.current = document.activeElement;
42
- }
43
- }, [open]);
44
- useEffect(() => {
45
- if (!open) return;
46
- const panel = panelRef.current;
47
- if (panel) {
48
- const first = panel.querySelector(
49
- "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])"
50
- ) ?? panel.querySelector(FOCUSABLE);
51
- (first ?? panel).focus();
52
- }
53
- const handleKeyDown = (e) => {
54
- if (e.key === "Escape" && dismissible) {
55
- onOpenChange(false);
56
- return;
57
- }
58
- if (e.key === "Tab" && panel) {
59
- const focusable = panel.querySelectorAll(FOCUSABLE);
60
- if (focusable.length === 0) return;
61
- const first = focusable[0];
62
- const last = focusable[focusable.length - 1];
63
- if (e.shiftKey && document.activeElement === first) {
64
- e.preventDefault();
65
- last.focus();
66
- } else if (!e.shiftKey && document.activeElement === last) {
67
- e.preventDefault();
68
- first.focus();
69
- }
70
- }
71
- };
72
- document.addEventListener("keydown", handleKeyDown);
73
- return () => document.removeEventListener("keydown", handleKeyDown);
74
- }, [open, dismissible, onOpenChange]);
75
- useEffect(() => {
76
- if (!open && previousFocusRef.current) {
77
- previousFocusRef.current.focus();
78
- previousFocusRef.current = null;
79
- }
80
- }, [open]);
81
- useEffect(() => {
82
- if (open) {
83
- document.body.style.overflow = "hidden";
84
- } else {
85
- document.body.style.overflow = "";
86
- }
87
- return () => {
88
- document.body.style.overflow = "";
89
- };
90
- }, [open]);
34
+ const mounted = useMounted();
35
+ useLayer({ open, dismissOnEscape: dismissible, onDismiss: () => onOpenChange(false) });
36
+ useFocusScope(panelRef, { active: open });
37
+ useScrollLock(open);
91
38
  const handleScrimClick = () => {
92
39
  if (dismissible) onOpenChange(false);
93
40
  };
@@ -63,9 +63,10 @@ const Dropdown = React.forwardRef(
63
63
  }
64
64
  }, []);
65
65
  useEffect(() => {
66
+ if (!isOpen) return;
66
67
  document.addEventListener("mousedown", handleClickOutside);
67
68
  return () => document.removeEventListener("mousedown", handleClickOutside);
68
- }, [handleClickOutside]);
69
+ }, [isOpen, handleClickOutside]);
69
70
  const handleKeyDown = (e) => {
70
71
  if (disabled) return;
71
72
  switch (e.key) {
@@ -177,6 +177,7 @@ const DropdownMenu = ({
177
177
  setFocusedIndex(-1);
178
178
  }, []);
179
179
  useEffect(() => {
180
+ if (!isOpen) return;
180
181
  const handler = (e) => {
181
182
  if (rootRef.current && !rootRef.current.contains(e.target)) {
182
183
  handleClose();
@@ -184,7 +185,7 @@ const DropdownMenu = ({
184
185
  };
185
186
  document.addEventListener("mousedown", handler);
186
187
  return () => document.removeEventListener("mousedown", handler);
187
- }, [handleClose]);
188
+ }, [isOpen, handleClose]);
188
189
  const handleKeyDown = (e) => {
189
190
  switch (e.key) {
190
191
  case "Enter":
@@ -323,14 +324,21 @@ const DropdownMenu = ({
323
324
  size === "compact" ? `${baseClass}__panel--compact` : ""
324
325
  ].filter(Boolean).join(" ");
325
326
  const itemCounter = { count: 0 };
327
+ const triggerProps = React.isValidElement(trigger) ? trigger.props : void 0;
326
328
  return /* @__PURE__ */ jsxs("div", { className: classes, ref: rootRef, children: [
327
329
  /* @__PURE__ */ jsx("div", { className: `${baseClass}__trigger`, children: React.isValidElement(trigger) ? React.cloneElement(trigger, {
328
330
  "aria-haspopup": "menu",
329
331
  "aria-expanded": isOpen,
330
332
  "aria-controls": isOpen ? menuId : void 0,
331
333
  "aria-activedescendant": activeDescendantId,
332
- onClick: handleToggle,
333
- onKeyDown: handleKeyDown
334
+ onClick: (e) => {
335
+ triggerProps?.onClick?.(e);
336
+ handleToggle();
337
+ },
338
+ onKeyDown: (e) => {
339
+ triggerProps?.onKeyDown?.(e);
340
+ handleKeyDown(e);
341
+ }
334
342
  }) : /* @__PURE__ */ jsx(
335
343
  "button",
336
344
  {
@@ -47,15 +47,25 @@
47
47
  ZOOMABLE (has onClick)
48
48
  ============================================ */
49
49
 
50
- .ds-figure--zoomable {
50
+ /* The zoom control is a real <button> wrapping the media (ARIA forbids
51
+ role="button" on <figure>), stripped back to a bare block. */
52
+ .ds-figure__zoom {
53
+ display: block;
54
+ width: 100%;
55
+ padding: 0;
56
+ border: 0;
57
+ background: none;
58
+ font: inherit;
51
59
  cursor: zoom-in;
52
60
  }
53
61
 
54
- .ds-figure--zoomable:hover .ds-figure__media img {
62
+ .ds-figure__zoom:hover .ds-figure__media img {
55
63
  opacity: 0.88;
56
64
  }
57
65
 
58
- .ds-figure--zoomable:focus-visible {
66
+ /* Inset ring: the button sits inside the figure's overflow clip, so an
67
+ outset outline would be cropped away. */
68
+ .ds-figure__zoom:focus-visible {
59
69
  outline: 2px solid var(--color-action-primary-bg);
60
- outline-offset: 2px;
70
+ outline-offset: -2px;
61
71
  }
@@ -9,26 +9,17 @@ const Figure = ({
9
9
  }) => {
10
10
  const baseClass = "ds-figure";
11
11
  const classes = [baseClass, onClick && `${baseClass}--zoomable`, className].filter(Boolean).join(" ");
12
- const onKeyDown = onClick ? (e) => {
13
- if (e.key === "Enter" || e.key === " ") {
14
- e.preventDefault();
15
- onClick();
16
- }
17
- } : void 0;
18
- return /* @__PURE__ */ jsxs(
19
- "figure",
20
- {
21
- className: classes,
22
- onClick,
23
- onKeyDown,
24
- role: onClick ? "button" : void 0,
25
- tabIndex: onClick ? 0 : void 0,
26
- children: [
27
- /* @__PURE__ */ jsx("div", { className: `${baseClass}__media`, children }),
28
- caption && /* @__PURE__ */ jsx("figcaption", { className: `${baseClass}__caption`, children: caption })
29
- ]
30
- }
31
- );
12
+ const media = /* @__PURE__ */ jsx("div", { className: `${baseClass}__media`, children });
13
+ return /* @__PURE__ */ jsxs("figure", { className: classes, children: [
14
+ onClick ? (
15
+ // A real button rather than role="button" on the figure: ARIA
16
+ // forbids the button role on <figure>, and the element brings
17
+ // Enter/Space activation itself. Its accessible name is the
18
+ // child image's alt text.
19
+ /* @__PURE__ */ jsx("button", { type: "button", className: `${baseClass}__zoom`, onClick, children: media })
20
+ ) : media,
21
+ caption && /* @__PURE__ */ jsx("figcaption", { className: `${baseClass}__caption`, children: caption })
22
+ ] });
32
23
  };
33
24
  export {
34
25
  Figure