@getdom/studio 0.1.1 → 0.2.1

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/CHANGELOG.md +54 -0
  2. package/README.md +31 -0
  3. package/SKILL.md +21 -1
  4. package/package.json +10 -4
  5. package/public/llms.txt +13 -2
  6. package/src/pages/_layout/componentCatalog.generated.js +1185 -44
  7. package/src/pages/_layout/componentLoaders.js +41 -0
  8. package/src/pages/_layout/componentManager.js +38 -27
  9. package/src/pages/_layout/componentRuntimeRecords.js +3 -1
  10. package/src/pages/charts/_shared/ChartContainer.vue +67 -0
  11. package/src/pages/charts/_shared/ChartLegend.vue +22 -0
  12. package/src/pages/charts/_shared/ChartTooltip.vue +18 -0
  13. package/src/pages/charts/_shared/DonutRenderer.vue +33 -0
  14. package/src/pages/charts/_shared/XYChart.vue +140 -0
  15. package/src/pages/charts/_shared/XYRenderer.vue +90 -0
  16. package/src/pages/charts/_shared/chartProps.js +23 -0
  17. package/src/pages/charts/_shared/chartUtils.js +95 -0
  18. package/src/pages/charts/area-chart/DomAreaChart.vue +27 -0
  19. package/src/pages/charts/bar-chart/DomBarChart.vue +29 -0
  20. package/src/pages/charts/date-range-picker/DomDateRangePicker.vue +104 -0
  21. package/src/pages/charts/date-range-picker/dateRange.js +69 -0
  22. package/src/pages/charts/donut-chart/DomDonutChart.vue +117 -0
  23. package/src/pages/charts/line-chart/DomLineChart.vue +26 -0
  24. package/src/pages/charts/metric-card/DomMetricCard.vue +49 -0
  25. package/src/pages/charts/sparkline/DomSparkline.vue +30 -0
  26. package/src/pages/components/_shared/media/MediaDeviceSelect.vue +28 -0
  27. package/src/pages/components/_shared/media/useMediaInput.js +200 -0
  28. package/src/pages/components/audio-controls/DomAudioControls.vue +91 -0
  29. package/src/pages/components/audio-input/DomAudioInput.vue +58 -0
  30. package/src/pages/components/button/DomButton.vue +10 -4
  31. package/src/pages/components/button/buttonStyles.js +5 -0
  32. package/src/pages/components/camera-input/DomCameraInput.vue +75 -0
  33. package/src/pages/components/command-palette/DomCommandPalette.vue +12 -2
  34. package/src/pages/components/dialog/DomDialog.vue +338 -36
  35. package/src/pages/components/icon-button/DomIconButton.vue +8 -2
  36. package/src/pages/components/menu-stack/DomMenuStack.vue +111 -0
  37. package/src/pages/components/menu-stack/menuTree.js +35 -0
  38. package/src/pages/components/popover/DomPopover.vue +1 -53
  39. package/src/pages/components/popover/popover.css +50 -0
  40. package/src/pages/components/video-feed/DomVideoFeed.vue +551 -0
  41. package/src/pages/data/agent-chat/DomAgentChatShell.vue +1 -0
  42. package/src/pages/data/agent-chat/DomAgentConversation.vue +1 -0
  43. package/src/pages/data/agent-chat/DomAgentConversationList.vue +1 -0
  44. package/src/pages/data/gantt/DomGantt.vue +788 -0
  45. package/src/pages/data/gantt/ganttTime.js +223 -0
  46. package/src/pages/data/month-calendar/DomMonthCalendar.vue +84 -9
  47. package/src/pages/data/notification-inbox/DomNotificationInbox.vue +505 -0
  48. package/src/pages/data/notification-inbox/notificationPresentation.js +34 -0
  49. package/src/pages/designer/_shared/ControlIconButton.vue +62 -0
  50. package/src/pages/designer/_shared/StyleControlSection.vue +59 -0
  51. package/src/pages/designer/_shared/cssEditorContext.js +19 -0
  52. package/src/pages/designer/backdrop-editor/BackdropControl.vue +57 -0
  53. package/src/pages/designer/backdrop-editor/DomBackdropEditor.vue +33 -0
  54. package/src/pages/designer/backdrop-editor/backdropModel.js +39 -0
  55. package/src/pages/designer/background-editor/BackgroundLayers.vue +210 -0
  56. package/src/pages/designer/background-editor/DomBackgroundEditor.vue +33 -0
  57. package/src/pages/designer/background-editor/backgroundModel.js +102 -0
  58. package/src/pages/designer/border-editor/BorderControl.vue +72 -0
  59. package/src/pages/designer/border-editor/DomBorderEditor.vue +34 -0
  60. package/src/pages/designer/border-editor/borderModel.js +21 -0
  61. package/src/pages/designer/color-swatch/DomColorSwatch.vue +95 -0
  62. package/src/pages/designer/frame/DomDesignerFrame.vue +16 -0
  63. package/src/pages/designer/frame/DomFrameLayoutEditor.vue +41 -0
  64. package/src/pages/designer/frame/frameLayout.js +93 -0
  65. package/src/pages/designer/layers/DomDesignerLayers.vue +11 -0
  66. package/src/pages/designer/shadow-editor/DomShadowEditor.vue +32 -0
  67. package/src/pages/designer/shadow-editor/ShadowLayers.vue +185 -0
  68. package/src/pages/designer/shadow-editor/shadowModel.js +73 -0
  69. package/src/pages/designer/stage/DesignerBounds.vue +31 -0
  70. package/src/pages/designer/stage/DomDesignerStage.vue +237 -0
  71. package/src/pages/designer/stage/SpacingGuides.vue +33 -0
  72. package/src/pages/designer/stage/resizeGeometry.js +15 -0
  73. package/src/pages/designer/stage/snapGeometry.js +69 -0
  74. package/src/pages/designer/stage/snapPipeline.js +17 -0
  75. package/src/pages/designer/stage/spacingGeometry.js +129 -0
  76. package/src/pages/forms/native-select/DomNativeSelect.vue +8 -3
  77. package/src/pages/forms/number-input/DomNumberInput.vue +39 -3
  78. package/src/pages/forms/range-input/DomRangeInput.vue +75 -23
  79. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +91 -9
  80. package/src/pages/forms/toggle/DomToggle.vue +1 -1
  81. package/src/pages/lib/headless/base.js +10 -5
  82. package/src/pages/lib/headless/dialog.js +25 -6
  83. package/src/pages/lib/headless/drawer.js +49 -21
  84. package/src/pages/lib/vue/index.d.ts +1161 -14
  85. package/src/pages/lib/vue/index.js +43 -0
  86. package/src/pages/mobile/_shared/interactionMath.js +46 -0
  87. package/src/pages/mobile/_shared/useKeyboardViewport.js +91 -0
  88. package/src/pages/mobile/action-sheet/DomActionSheet.vue +102 -65
  89. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +4 -2
  90. package/src/pages/mobile/bottom-sheet/DomBottomSheet.vue +221 -0
  91. package/src/pages/mobile/list-item/DomAppListItem.vue +6 -6
  92. package/src/pages/mobile/profile/DomAppProfile.vue +42 -0
  93. package/src/pages/mobile/safe-area/DomSafeArea.vue +6 -6
  94. package/src/pages/mobile/shell/DomAppShell.vue +60 -9
  95. package/src/pages/mobile/stack/DomAppStack.vue +214 -55
  96. package/src/pages/mobile/swipe-actions/DomSwipeActions.vue +346 -0
  97. package/src/pages/mobile/top-bar/DomAppTopBar.vue +3 -3
  98. package/src/pages/visual/audio-visualizer/DomAudioVisualizer.vue +143 -0
  99. package/src/theme.css +13 -0
@@ -11,6 +11,10 @@ defineOptions({
11
11
  description: 'A compact slider input for numeric values with a stylable track and handle.',
12
12
  icon: 'M5 12h14M8 12a2 2 0 1 0 0.01 0M16 12a2 2 0 1 0 0.01 0',
13
13
  slots: [],
14
+ events: [
15
+ { name: 'update:modelValue', payload: 'number', description: 'The value changed through pointer or keyboard input.' },
16
+ { name: 'update:dragging', payload: 'boolean', description: 'Pointer scrubbing started or finished, including cancellation.' },
17
+ ],
14
18
  playground: {
15
19
  initial: {
16
20
  label: 'Confidence',
@@ -60,6 +64,11 @@ const props = defineProps({
60
64
  default: '',
61
65
  _edit: { description: 'Classes applied to the visual range track.' },
62
66
  },
67
+ controlClass: {
68
+ type: [String, Array, Object],
69
+ default: '',
70
+ _edit: { description: 'Classes applied to the interactive track area, including its touch target height.' },
71
+ },
63
72
  progressClass: {
64
73
  type: [String, Array, Object],
65
74
  default: '',
@@ -76,11 +85,14 @@ const props = defineProps({
76
85
  _edit: { description: 'Additional classes applied to the handle while it is being dragged.' },
77
86
  },
78
87
  });
79
- const emit = defineEmits(['update:modelValue']);
88
+ const emit = defineEmits(['update:modelValue', 'update:dragging', 'focus', 'blur']);
80
89
  const field = useField(props, emit, { idPrefix: 'dom-range-input' });
81
90
  const control = ref(null);
82
91
  const rangeInput = ref(null);
83
92
  const dragging = ref(false);
93
+ const keyboardFocused = ref(false);
94
+ let dragWindow;
95
+ let dragPointerId;
84
96
 
85
97
  const displayValue = computed(() => {
86
98
  const value = field.value.value === '' ? props.min : Number(field.value.value);
@@ -110,10 +122,11 @@ const handleStyle = computed(() => ({
110
122
  left: `${progress.value}%`,
111
123
  }));
112
124
 
113
- const controlClass = computed(() => [
114
- 'dom-range-input__control group relative min-h-7 w-full rounded-full outline-none',
125
+ const controlClassName = computed(() => [
126
+ 'dom-range-input__control group group/range relative min-h-7 w-full touch-none rounded-full outline-none',
115
127
  field.disabled.value || field.readOnly.value ? 'cursor-not-allowed' : 'cursor-pointer',
116
128
  (field.disabled.value || field.readOnly.value) && 'opacity-50',
129
+ props.controlClass,
117
130
  ]);
118
131
 
119
132
  const inputClass = computed(() => [
@@ -126,12 +139,12 @@ const rangeInputAttrs = computed(() => ({
126
139
  }));
127
140
 
128
141
  const trackClassName = computed(() => [
129
- 'dom-range-input__bar pointer-events-none absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-secondary',
142
+ 'pointer-events-none absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-secondary shadow-[inset_0_1px_2px_color-mix(in_oklch,var(--border)_70%,transparent)]',
130
143
  props.trackClass,
131
144
  ]);
132
145
 
133
146
  const progressClassName = computed(() => [
134
- 'dom-range-input__bar pointer-events-none absolute left-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-primary',
147
+ 'pointer-events-none absolute left-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-primary shadow-[inset_0_1px_2px_color-mix(in_oklch,var(--border)_70%,transparent)]',
135
148
  props.progressClass,
136
149
  ]);
137
150
 
@@ -141,11 +154,13 @@ const handleClassName = computed(() => [
141
154
  dragging.value && props.activeHandleClass,
142
155
  ]);
143
156
 
157
+ /** Converts native range values to numbers without discarding unparseable input. */
144
158
  function toNumber(value) {
145
159
  const number = Number(value);
146
160
  return Number.isNaN(number) ? value : number;
147
161
  }
148
162
 
163
+ /** Commits native input changes while preserving read-only field values. */
149
164
  function onInput(value, event) {
150
165
  if (field.readOnly.value) {
151
166
  event.target.value = displayValue.value;
@@ -155,51 +170,75 @@ function onInput(value, event) {
155
170
  field.onInput(toNumber(value));
156
171
  }
157
172
 
173
+ /** Starts one primary-pointer drag in the control's owning window, including iframe previews. */
158
174
  function beginDrag(event) {
175
+ if (event.button !== 0 || !event.isPrimary || dragging.value) return;
159
176
  if (field.disabled.value || field.readOnly.value) {
160
177
  event.preventDefault();
161
178
  return;
162
179
  }
163
180
 
164
181
  event.preventDefault();
165
- rangeInput.value?.focus({ preventScroll: true });
182
+ keyboardFocused.value = false;
166
183
  dragging.value = true;
184
+ rangeInput.value?.focus({ preventScroll: true });
185
+ dragPointerId = event.pointerId;
186
+ dragWindow = control.value.ownerDocument.defaultView;
187
+ emit('update:dragging', true);
167
188
  updateFromPointer(event);
168
- window.addEventListener('pointermove', moveDrag);
169
- window.addEventListener('pointerup', endDrag, { once: true });
170
- window.addEventListener('pointercancel', endDrag, { once: true });
189
+ dragWindow.addEventListener('pointermove', moveDrag);
190
+ dragWindow.addEventListener('pointerup', endDrag);
191
+ dragWindow.addEventListener('pointercancel', endDrag);
192
+ dragWindow.addEventListener('blur', endDrag);
171
193
  }
172
194
 
195
+ /** Follows only the pointer that started the current drag. */
173
196
  function moveDrag(event) {
197
+ if (event.pointerId !== dragPointerId) return;
198
+ if (field.disabled.value || field.readOnly.value) {
199
+ endDrag();
200
+ return;
201
+ }
174
202
  event.preventDefault();
175
203
  updateFromPointer(event);
176
204
  }
177
205
 
178
- function endDrag() {
206
+ /** Releases drag listeners on completion, cancellation, window blur, or unmount. */
207
+ function endDrag(event) {
208
+ if (event?.pointerId != null && event.pointerId !== dragPointerId) return;
209
+ const wasDragging = dragging.value;
179
210
  dragging.value = false;
180
- window.removeEventListener('pointermove', moveDrag);
181
- window.removeEventListener('pointerup', endDrag);
182
- window.removeEventListener('pointercancel', endDrag);
211
+ dragWindow?.removeEventListener('pointermove', moveDrag);
212
+ dragWindow?.removeEventListener('pointerup', endDrag);
213
+ dragWindow?.removeEventListener('pointercancel', endDrag);
214
+ dragWindow?.removeEventListener('blur', endDrag);
215
+ dragWindow = undefined;
216
+ dragPointerId = undefined;
217
+ if (wasDragging) emit('update:dragging', false);
183
218
  }
184
219
 
220
+ /** Writes the pointer's clamped and step-aligned value to the shared field state. */
185
221
  function updateFromPointer(event) {
186
222
  field.onInput(valueFromPointer(event));
187
223
  }
188
224
 
225
+ /** Maps horizontal pointer position onto the finite numeric range. */
189
226
  function valueFromPointer(event) {
190
227
  const rect = control.value?.getBoundingClientRect();
191
228
  const min = Number(props.min);
192
229
  const max = Number(props.max);
193
230
  const range = max - min;
194
231
 
195
- if (!rect || !Number.isFinite(min) || !Number.isFinite(max) || range <= 0) return displayValue.value;
232
+ if (!rect?.width || !Number.isFinite(min) || !Number.isFinite(max) || range <= 0) return displayValue.value;
196
233
 
197
234
  const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
198
235
  return snapValue(min + ratio * range);
199
236
  }
200
237
 
238
+ /** Implements native slider keyboard navigation with the same bounds and stepping as pointer input. */
201
239
  function onKeydown(event) {
202
240
  if (field.disabled.value || field.readOnly.value) return;
241
+ keyboardFocused.value = true;
203
242
 
204
243
  const step = stepSize();
205
244
  const current = displayValue.value;
@@ -219,6 +258,19 @@ function onKeydown(event) {
219
258
  field.onInput(snapValue(next));
220
259
  }
221
260
 
261
+ /** Reveals keyboard focus while excluding the programmatic focus used during pointer scrubbing. */
262
+ function onFocus(event) {
263
+ keyboardFocused.value = !dragging.value;
264
+ field.onFocus(event);
265
+ }
266
+
267
+ /** Clears the keyboard indicator and preserves shared field blur and validation behavior. */
268
+ function onBlur(event) {
269
+ keyboardFocused.value = false;
270
+ field.onBlur(event);
271
+ }
272
+
273
+ /** Clamps a requested value and snaps it to the nearest increment relative to the minimum. */
222
274
  function snapValue(value) {
223
275
  const min = Number(props.min);
224
276
  const max = Number(props.max);
@@ -234,16 +286,19 @@ function snapValue(value) {
234
286
  return roundToStep(next, step);
235
287
  }
236
288
 
289
+ /** Resolves a positive step, falling back to the native range default. */
237
290
  function stepSize() {
238
291
  const step = Number(props.step);
239
292
  return Number.isFinite(step) && step > 0 ? step : 1;
240
293
  }
241
294
 
295
+ /** Removes floating-point noise without losing precision required by the configured step. */
242
296
  function roundToStep(value, step) {
243
297
  const decimals = decimalPlaces(step);
244
298
  return Number(value.toFixed(Math.min(12, decimals + 2)));
245
299
  }
246
300
 
301
+ /** Counts decimal precision for ordinary and negative-exponent step values. */
247
302
  function decimalPlaces(value) {
248
303
  const text = String(value);
249
304
  if (text.includes('e-')) return Number(text.split('e-')[1]) || 0;
@@ -259,9 +314,10 @@ onBeforeUnmount(endDrag);
259
314
  <div class="flex w-full items-center gap-3">
260
315
  <div
261
316
  ref="control"
262
- :class="controlClass"
317
+ :class="controlClassName"
263
318
  :style="rangeStyle"
264
319
  :data-dragging="dragging ? 'true' : undefined"
320
+ :data-keyboard-focus="keyboardFocused ? '' : undefined"
265
321
  @pointerdown="beginDrag"
266
322
  >
267
323
  <span aria-hidden="true" :class="trackClassName"></span>
@@ -270,6 +326,8 @@ onBeforeUnmount(endDrag);
270
326
  <input
271
327
  ref="rangeInput"
272
328
  v-bind="rangeInputAttrs"
329
+ :aria-label="$attrs['aria-label']"
330
+ :aria-valuetext="$attrs['aria-valuetext']"
273
331
  type="range"
274
332
  :min="min"
275
333
  :max="max"
@@ -278,8 +336,8 @@ onBeforeUnmount(endDrag);
278
336
  :class="inputClass"
279
337
  @input="onInput($event.target.value, $event)"
280
338
  @keydown="onKeydown"
281
- @focus="field.onFocus"
282
- @blur="field.onBlur"
339
+ @focus="onFocus"
340
+ @blur="onBlur"
283
341
  />
284
342
  </div>
285
343
  <output v-if="showValue" class="min-w-10 text-right font-mono text-xs text-muted-fg">
@@ -288,9 +346,3 @@ onBeforeUnmount(endDrag);
288
346
  </div>
289
347
  </DomField>
290
348
  </template>
291
-
292
- <style scoped>
293
- .dom-range-input__bar {
294
- box-shadow: inset 0 1px 2px color-mix(in oklch, var(--border) 70%, transparent);
295
- }
296
- </style>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed } from 'vue';
2
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
3
3
  import DomIconButton from '../../components/icon-button/DomIconButton.vue';
4
4
  import DomField from '../field/DomField.vue';
5
5
  import { fieldProps } from '../field/fieldProps.js';
@@ -23,11 +23,11 @@ defineOptions({
23
23
  { name: 'attachments', payload: '{ value, disabled, readOnly }', description: 'File, image, reference, or context previews shown above the text.' },
24
24
  { name: 'leading', payload: '{ value, submit, disabled }', description: 'Controls at the start of the bottom action row, such as add or tool buttons.' },
25
25
  { name: 'actions', payload: '{ value, submit, disabled }', description: 'Additional controls before the built-in submit action.' },
26
- { name: 'submit', payload: '{ submit, disabled, submitting, label }', description: 'Replace the built-in circular submit button.' },
26
+ { name: 'submit', payload: '{ submit, disabled, submitting, label, pressed }', description: 'Replace the built-in circular submit button. Bind pressed to DomButton or DomIconButton to retain keyboard submit feedback.' },
27
27
  ],
28
28
  keyboard: [
29
- { keys: 'Enter', action: 'Submit when submitOnEnter is enabled.' },
30
- { keys: 'Shift + Enter', action: 'Insert a new line.' },
29
+ { keys: 'Enter', action: 'Submit with button press feedback when submitOnEnter is enabled; otherwise insert a new line.' },
30
+ { keys: 'Shift + Enter', action: 'Insert a new line when submitOnEnter is enabled; otherwise submit with button press feedback.' },
31
31
  ],
32
32
  },
33
33
  });
@@ -77,7 +77,7 @@ const props = defineProps({
77
77
  submitOnEnter: {
78
78
  type: Boolean,
79
79
  default: true,
80
- _edit: { group: 'Submit props', description: 'Submit on Enter while Shift + Enter continues to add a new line.' },
80
+ _edit: { group: 'Submit props', description: 'When enabled, Enter submits and Shift + Enter adds a new line. When disabled, Enter adds a new line and Shift + Enter submits.' },
81
81
  },
82
82
  allowEmptySubmit: {
83
83
  type: Boolean,
@@ -107,6 +107,85 @@ const { textarea, resize } = useTextareaAutosize({
107
107
  rows,
108
108
  maxRows,
109
109
  });
110
+ const submitPressed = ref(false);
111
+ let enterHeld = false;
112
+ let pressTimer;
113
+ let ownerWindow;
114
+
115
+ /**
116
+ * Keeps quick submit shortcuts visible while retaining feedback for a held key.
117
+ *
118
+ * @returns {void}
119
+ */
120
+ function pressSubmit() {
121
+ clearTimeout(pressTimer);
122
+ enterHeld = true;
123
+ submitPressed.value = true;
124
+ pressTimer = setTimeout(() => {
125
+ pressTimer = undefined;
126
+ if (!enterHeld) submitPressed.value = false;
127
+ }, 150);
128
+ }
129
+
130
+ /**
131
+ * Releases Enter feedback even if submission moved focus or disabled the field.
132
+ *
133
+ * @param {KeyboardEvent} event Native window keyup event.
134
+ * @returns {void}
135
+ */
136
+ function onKeyup(event) {
137
+ if (event.key !== 'Enter') return;
138
+ enterHeld = false;
139
+ if (pressTimer === undefined) submitPressed.value = false;
140
+ }
141
+
142
+ /**
143
+ * Clears pending feedback when focus leaves the composer or its window.
144
+ *
145
+ * @returns {void}
146
+ */
147
+ function resetSubmitPress() {
148
+ clearTimeout(pressTimer);
149
+ pressTimer = undefined;
150
+ enterHeld = false;
151
+ submitPressed.value = false;
152
+ }
153
+
154
+ /**
155
+ * Preserves field blur handling and cancels any outstanding Enter press.
156
+ *
157
+ * @param {FocusEvent} event Native textarea blur event.
158
+ * @returns {void}
159
+ */
160
+ function onBlur(event) {
161
+ resetSubmitPress();
162
+ field.onBlur(event);
163
+ }
164
+
165
+ /**
166
+ * Observes key release and window blur in the textarea's owning document.
167
+ *
168
+ * @returns {void}
169
+ */
170
+ function mountPressListeners() {
171
+ ownerWindow = textarea.value?.ownerDocument.defaultView ?? window;
172
+ ownerWindow?.addEventListener('keyup', onKeyup, true);
173
+ ownerWindow?.addEventListener('blur', resetSubmitPress);
174
+ }
175
+
176
+ /**
177
+ * Removes native listeners and timers when the composer is unmounted.
178
+ *
179
+ * @returns {void}
180
+ */
181
+ function unmountPressListeners() {
182
+ ownerWindow?.removeEventListener('keyup', onKeyup, true);
183
+ ownerWindow?.removeEventListener('blur', resetSubmitPress);
184
+ resetSubmitPress();
185
+ }
186
+
187
+ onMounted(mountPressListeners);
188
+ onBeforeUnmount(unmountPressListeners);
110
189
 
111
190
  /**
112
191
  * Writes native composer input through the shared form-field contract.
@@ -131,7 +210,7 @@ function submit(event) {
131
210
  }
132
211
 
133
212
  /**
134
- * Exposes keyboard activity and applies the composer Enter convention.
213
+ * Exposes keyboard activity and applies the selected submit/newline shortcut pair.
135
214
  *
136
215
  * @param {KeyboardEvent} event Native keydown event.
137
216
  * @returns {void}
@@ -140,12 +219,13 @@ function onKeydown(event) {
140
219
  emit('keydown', event);
141
220
  if (
142
221
  event.defaultPrevented
143
- || !props.submitOnEnter
144
222
  || event.isComposing
145
223
  || event.key !== 'Enter'
146
- || event.shiftKey
224
+ || event.shiftKey === props.submitOnEnter
147
225
  ) return;
148
226
  event.preventDefault();
227
+ if (event.repeat || enterHeld || !canSubmit.value) return;
228
+ pressSubmit();
149
229
  submit(event);
150
230
  }
151
231
 
@@ -198,7 +278,7 @@ defineExpose({ focus, resize });
198
278
  @input="onInput"
199
279
  @keydown="onKeydown"
200
280
  @focus="field.onFocus"
201
- @blur="field.onBlur"
281
+ @blur="onBlur"
202
282
  ></textarea>
203
283
 
204
284
  <div
@@ -217,11 +297,13 @@ defineExpose({ focus, resize });
217
297
  :disabled="!canSubmit"
218
298
  :submitting="submitting"
219
299
  :label="submitLabel"
300
+ :pressed="submitPressed"
220
301
  >
221
302
  <DomIconButton
222
303
  :label="submitLabel"
223
304
  :disabled="!canSubmit"
224
305
  :loading="submitting"
306
+ :pressed="submitPressed"
225
307
  variant="primary"
226
308
  icon="M12 19V5m0 0-6 6m6-6 6 6"
227
309
  @click="submit"
@@ -33,7 +33,7 @@ const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
33
33
 
34
34
  const root = ref(null);
35
35
  const field = useField(props, emit, { idPrefix: 'dom-toggle' });
36
- const labelId = computed(() => (props.label ? `${field.id.value}-label` : ''));
36
+ const labelId = computed(() => (props.label && props.chrome !== 'none' ? `${field.id.value}-label` : ''));
37
37
  const sizeConfig = computed(() => ({
38
38
  sm: {
39
39
  wrapper: 'gap-2',
@@ -60,7 +60,7 @@ export class DomElementBase extends (isBrowser ? HTMLElement : class {}) {
60
60
  }
61
61
  }
62
62
 
63
- // Tiny focus-trap used by dialog & combobox panel.
63
+ /** Keeps Tab navigation inside a panel, including panels adopted into another document. */
64
64
  export function focusTrap(container) {
65
65
  const sel = [
66
66
  'a[href]', 'button:not([disabled])', 'input:not([disabled])',
@@ -69,14 +69,19 @@ export function focusTrap(container) {
69
69
  ].join(',');
70
70
  const onKey = (e) => {
71
71
  if (e.key !== 'Tab') return;
72
- const nodes = container.querySelectorAll(sel);
73
- if (!nodes.length) return;
72
+ const nodes = [...container.querySelectorAll(sel)].filter((node) => !node.closest('[inert], [hidden]') && node.getClientRects().length > 0);
73
+ if (!nodes.length) {
74
+ e.preventDefault();
75
+ container.focus({ preventScroll: true });
76
+ return;
77
+ }
74
78
  const first = nodes[0];
75
79
  const last = nodes[nodes.length - 1];
76
- if (e.shiftKey && document.activeElement === first) {
80
+ const active = container.ownerDocument.activeElement;
81
+ if (e.shiftKey && (active === first || active === container)) {
77
82
  e.preventDefault();
78
83
  last.focus();
79
- } else if (!e.shiftKey && document.activeElement === last) {
84
+ } else if (!e.shiftKey && (active === last || active === container)) {
80
85
  e.preventDefault();
81
86
  first.focus();
82
87
  }
@@ -51,9 +51,9 @@ function getTemplate() {
51
51
  -webkit-backdrop-filter: blur(4px);
52
52
  opacity: 0;
53
53
  transition:
54
- opacity 140ms ease,
55
- overlay 140ms ease allow-discrete,
56
- display 140ms ease allow-discrete;
54
+ opacity var(--dom-dialog-backdrop-duration, 140ms) ease,
55
+ overlay var(--dom-dialog-backdrop-duration, 140ms) ease allow-discrete,
56
+ display var(--dom-dialog-backdrop-duration, 140ms) ease allow-discrete;
57
57
  }
58
58
  dialog[open]::backdrop { opacity: 1; }
59
59
  :host([no-backdrop]) dialog::backdrop {
@@ -153,6 +153,7 @@ export class DomDialogElement extends DomElementBase {
153
153
  events: [
154
154
  { name: 'dom:before-open', description: 'Fired immediately before the dialog opens so callers can prepare render state.' },
155
155
  { name: 'dom:open', description: 'Fired when the dialog opens.' },
156
+ { name: 'dom:request-close', description: 'Cancelable dismissal request with { reason: "escape" | "backdrop" | "control" }. Prevent the event to keep the dialog open.' },
156
157
  { name: 'dom:close', description: 'Fired when the dialog closes (Esc, .close(), backdrop, or data-close).' },
157
158
  { name: 'dom:close-complete', description: 'Fired after the close transition completes so callers can release retained data.' },
158
159
  ],
@@ -207,16 +208,17 @@ export class DomDialogElement extends DomElementBase {
207
208
 
208
209
  this.on(this, 'click', (ev) => {
209
210
  const closer = ev.target.closest?.('[data-close]');
210
- if (closer && this.contains(closer)) this._setOpen(false);
211
+ if (closer && this.contains(closer)) this._requestClose('control');
211
212
  });
212
213
 
213
214
  this.on(this._dialog, 'click', (e) => {
214
215
  if (this.hasAttribute('static')) return;
215
- if (e.target === this._dialog) this._setOpen(false);
216
+ if (e.target === this._dialog) this._requestClose('backdrop');
216
217
  });
217
218
 
218
219
  this.on(this._dialog, 'cancel', (e) => {
219
- if (this.hasAttribute('static')) e.preventDefault();
220
+ e.preventDefault();
221
+ this._requestClose('escape');
220
222
  });
221
223
  this.on(this._dialog, 'keydown', (e) => {
222
224
  if (this.hasAttribute('static') && e.key === 'Escape') e.preventDefault();
@@ -251,6 +253,23 @@ export class DomDialogElement extends DomElementBase {
251
253
  }
252
254
  }
253
255
 
256
+ /**
257
+ * Ask consumers whether a user-initiated dismissal may close the dialog.
258
+ *
259
+ * @param {'escape'|'backdrop'|'control'} reason Dismissal source.
260
+ * @returns {void}
261
+ */
262
+ _requestClose(reason) {
263
+ const request = new CustomEvent('dom:request-close', {
264
+ detail: { reason },
265
+ bubbles: true,
266
+ composed: true,
267
+ cancelable: true,
268
+ });
269
+ const blockedByStatic = this.hasAttribute('static') && reason !== 'control';
270
+ if (this.dispatchEvent(request) && !blockedByStatic) this._setOpen(false);
271
+ }
272
+
254
273
  /** Fallback when Invoker Commands are unavailable — wire [commandfor] buttons by id. */
255
274
  _wireCommandForButtons() {
256
275
  const id = this.id;