frappe-ui 1.0.0-beta.16 → 1.0.0-beta.18

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 (69) hide show
  1. package/experimental/FloatingWindow/FloatingWindow.cy.ts +24 -0
  2. package/experimental/FloatingWindow/FloatingWindow.vue +6 -4
  3. package/experimental/FloatingWindow/useFloatingWindow.ts +74 -82
  4. package/experimental/MultiEmailInput/MultiEmailInput.cy.ts +155 -0
  5. package/experimental/MultiEmailInput/MultiEmailInput.vue +472 -0
  6. package/experimental/MultiEmailInput/index.ts +17 -0
  7. package/experimental/MultiEmailInput/stories/AsyncSuggestions.vue +54 -0
  8. package/experimental/MultiEmailInput/stories/Basic.vue +18 -0
  9. package/experimental/MultiEmailInput/stories/CustomChip.vue +39 -0
  10. package/experimental/MultiEmailInput/stories/Labeling.vue +28 -0
  11. package/experimental/MultiEmailInput/types.ts +138 -0
  12. package/experimental/MultiEmailInput/utils.ts +60 -0
  13. package/experimental.ts +1 -0
  14. package/package.json +4 -1
  15. package/src/components/Autocomplete/Autocomplete.vue +3 -3
  16. package/src/components/Checkbox/Checkbox.cy.ts +10 -2
  17. package/src/components/Checkbox/Checkbox.vue +4 -18
  18. package/src/components/CodeEditor/CodeEditor.vue +0 -1
  19. package/src/components/Combobox/Combobox.vue +0 -1
  20. package/src/components/FormLabel.vue +5 -8
  21. package/src/components/InputLabeling/InputDescription.vue +1 -1
  22. package/src/components/InputLabeling/InputLabel.vue +21 -7
  23. package/src/components/MultiSelect/MultiSelect.vue +0 -1
  24. package/src/components/Popover/Popover.cy.ts +52 -0
  25. package/src/components/Popover/Popover.vue +17 -6
  26. package/src/components/Popover/types.ts +3 -0
  27. package/src/components/Rating/Rating.vue +3 -6
  28. package/src/components/ScrollArea/ScrollArea.api.md +59 -0
  29. package/src/components/ScrollArea/ScrollArea.vue +43 -0
  30. package/src/components/ScrollArea/ScrollBar.vue +83 -0
  31. package/src/components/ScrollArea/index.ts +2 -0
  32. package/src/components/Select/Select.cy.ts +16 -14
  33. package/src/components/Select/Select.vue +14 -15
  34. package/src/components/Select/stories/Example.vue +2 -1
  35. package/src/components/Select/utils.ts +2 -1
  36. package/src/components/SettingsDialog/SettingsBody.vue +23 -0
  37. package/src/components/SettingsDialog/SettingsContent.vue +5 -0
  38. package/src/components/SettingsDialog/SettingsDialog.api.md +273 -0
  39. package/src/components/SettingsDialog/SettingsDialog.cy.ts +126 -0
  40. package/src/components/SettingsDialog/SettingsDialog.md +43 -0
  41. package/src/components/SettingsDialog/SettingsDialog.vue +72 -0
  42. package/src/components/SettingsDialog/SettingsHeader.vue +36 -0
  43. package/src/components/SettingsDialog/SettingsNavGroup.vue +20 -0
  44. package/src/components/SettingsDialog/SettingsNavItem.vue +33 -0
  45. package/src/components/SettingsDialog/SettingsPanel.cy.ts +77 -0
  46. package/src/components/SettingsDialog/SettingsPanel.vue +26 -0
  47. package/src/components/SettingsDialog/SettingsRow.vue +52 -0
  48. package/src/components/SettingsDialog/SettingsSidebar.vue +16 -0
  49. package/src/components/SettingsDialog/index.ts +10 -0
  50. package/src/components/SettingsDialog/stories/Default.vue +99 -0
  51. package/src/components/SettingsDialog/stories/PanelBasic.vue +52 -0
  52. package/src/components/SettingsDialog/stories/panels/NotificationsPanel.vue +57 -0
  53. package/src/components/SettingsDialog/stories/panels/PreferencesPanel.vue +57 -0
  54. package/src/components/SettingsDialog/stories/panels/ProfilePanel.vue +38 -0
  55. package/src/components/SettingsDialog/stories/panels/UsersPanel.vue +83 -0
  56. package/src/components/SettingsDialog/types.ts +21 -0
  57. package/src/components/Slider/Slider.vue +0 -1
  58. package/src/components/Switch/Switch.vue +4 -20
  59. package/src/components/TextEditor/components/CodeBlockComponent.vue +4 -194
  60. package/src/components/TextEditor/components/MediaNodeView.vue +1 -1
  61. package/src/components/TextEditor/extensions/iframe/InsertIframe.vue +1 -1
  62. package/src/components/TextEditor/hljs-github.css +197 -0
  63. package/src/components/TextInput/TextInput.cy.ts +6 -1
  64. package/src/components/TextInput/TextInput.vue +7 -11
  65. package/src/components/Textarea/Textarea.vue +7 -11
  66. package/src/components/shared/selection/utils.ts +1 -1
  67. package/src/index.ts +2 -0
  68. package/src/molecules/editor/extensions/iframe/IframeInsertDialog.vue +1 -1
  69. package/tailwind/plugin.js +5 -2
@@ -265,6 +265,30 @@ describe('FloatingWindow', () => {
265
265
  })
266
266
  })
267
267
 
268
+ describe('pointercancel cleanup', () => {
269
+ it('stops resize and removes move listener when resize is cancelled by the browser', () => {
270
+ cy.mount(controlledWindow())
271
+ cy.get('[aria-label="Pop out"]').click()
272
+ cy.get('.floating-window').invoke('outerWidth').should('eq', 460)
273
+
274
+ // Start resize, then cancel (OS gesture / context-menu interrupt).
275
+ cy.get('[data-resize=se]').trigger('pointerdown', {
276
+ eventConstructor: 'PointerEvent',
277
+ button: 0,
278
+ clientX: 800,
279
+ clientY: 700,
280
+ })
281
+ cy.get('body').trigger('pointercancel', { eventConstructor: 'PointerEvent' })
282
+ // Move after cancel must be a no-op (listener torn down).
283
+ cy.get('body').trigger('pointermove', {
284
+ eventConstructor: 'PointerEvent',
285
+ clientX: 900,
286
+ clientY: 800,
287
+ })
288
+ cy.get('.floating-window').invoke('outerWidth').should('eq', 460)
289
+ })
290
+ })
291
+
268
292
  describe('edge and corner resize', () => {
269
293
  // Floating default is 460×520, parked bottom-right in the 1200×800 viewport:
270
294
  // x = 1200 - 460 - 24 = 716, y = 800 - 520 - 24 = 256.
@@ -24,6 +24,7 @@
24
24
  isFloating ? 'cursor-move' : '',
25
25
  $slots.header ? '' : 'gap-2 px-2.5 py-1.5',
26
26
  ]"
27
+ @pointerdown="startDrag"
27
28
  >
28
29
  <!-- Host-owned header: takes the whole row, supplies its own controls. -->
29
30
  <div v-if="$slots.header" class="min-w-0 flex-1">
@@ -113,7 +114,7 @@
113
114
  type="button"
114
115
  data-resize="se"
115
116
  aria-label="Resize window"
116
- class="absolute -bottom-1 -right-1 size-3 cursor-nwse-resize rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3"
117
+ class="absolute -bottom-1 -right-1 z-10 size-4 cursor-nwse-resize rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3"
117
118
  @pointerdown.prevent="startResize($event, { x: 1, y: 1 })"
118
119
  @keydown="onResizeKey"
119
120
  />
@@ -195,6 +196,7 @@ const handle = ref<HTMLElement | null>(null)
195
196
  const {
196
197
  mode,
197
198
  style,
199
+ startDrag,
198
200
  startResize,
199
201
  resizeBy,
200
202
  dock,
@@ -245,17 +247,17 @@ const resizeHandles: ResizeHandle[] = [
245
247
  {
246
248
  name: 'nw',
247
249
  dir: { x: -1, y: -1 },
248
- class: '-top-1 -left-1 size-3 cursor-nwse-resize',
250
+ class: '-top-1 -left-1 size-4 z-10 cursor-nwse-resize',
249
251
  },
250
252
  {
251
253
  name: 'ne',
252
254
  dir: { x: 1, y: -1 },
253
- class: '-top-1 -right-1 size-3 cursor-nesw-resize',
255
+ class: '-top-1 -right-1 size-4 z-10 cursor-nesw-resize',
254
256
  },
255
257
  {
256
258
  name: 'sw',
257
259
  dir: { x: -1, y: 1 },
258
- class: '-bottom-1 -left-1 size-3 cursor-nesw-resize',
260
+ class: '-bottom-1 -left-1 size-4 z-10 cursor-nesw-resize',
259
261
  },
260
262
  ]
261
263
 
@@ -6,12 +6,7 @@ import {
6
6
  type CSSProperties,
7
7
  type Ref,
8
8
  } from 'vue'
9
- import {
10
- useDraggable,
11
- useEventListener,
12
- useStorage,
13
- useWindowSize,
14
- } from '@vueuse/core'
9
+ import { useEventListener, useStorage, useWindowSize } from '@vueuse/core'
15
10
  import type {
16
11
  FloatingWindowOptions,
17
12
  Rect,
@@ -20,22 +15,12 @@ import type {
20
15
  } from './types'
21
16
 
22
17
  const TRAY_WIDTH = 320
23
- // Gap from the viewport edge when parked in the bottom-right corner.
24
18
  const EDGE_MARGIN = 24
25
- // Default floating size when a window pops out, absent any saved rect. Resizable
26
- // from there; the docked footprint is set by the host container, not these.
27
19
  const DEFAULT_WIDTH = 460
28
20
  const DEFAULT_HEIGHT = 520
29
- // Smallest the panel can be resized to.
30
21
  const MIN_WIDTH = 380
31
22
  const MIN_HEIGHT = 300
32
23
 
33
- // Only one window may be detached (floating or minimized) at a time. Opening a
34
- // new one pins the previous, like a single chat / mail composer. The active
35
- // window registers its `dock()` here so a newcomer can pin it; a global rule
36
- // keyed off `BODY_CLASS` lifts the popover layer above the detached window
37
- // (reka-ui teleports popovers to <body> at z-index:auto, which would otherwise
38
- // sit behind it).
39
24
  const BODY_CLASS = 'has-floating-window'
40
25
  let activeDocker: (() => void) | null = null
41
26
 
@@ -52,10 +37,9 @@ function clamp(value: number, min: number, max: number): number {
52
37
  * Headless drag + resize + window-state engine for FloatingWindow.
53
38
  *
54
39
  * Owns the geometry (x/y/width/height), the `mode` state machine, viewport
55
- * clamping and persistence. The shell component renders chrome and binds
56
- * `style`; this composable holds no markup. Drag is delegated to VueUse's
57
- * `useDraggable` and is only enabled while floating. Resize is a small custom
58
- * pointer loop, since VueUse has no resize composable.
40
+ * clamping and persistence. Drag and resize both use raw pointer events with
41
+ * `setPointerCapture` so the pointer never escapes to background images or
42
+ * links no jank, no native drag interference.
59
43
  */
60
44
  export function useFloatingWindow(
61
45
  panel: Ref<HTMLElement | null>,
@@ -77,26 +61,15 @@ export function useFloatingWindow(
77
61
  : ref({ mode: initialMode, rect: fallback })
78
62
 
79
63
  const mode = ref<WindowMode>(saved.value.mode)
64
+ const x = ref(saved.value.rect.x)
65
+ const y = ref(saved.value.rect.y)
80
66
  const width = ref(saved.value.rect.width)
81
67
  const height = ref(saved.value.rect.height)
68
+ const isDragging = ref(false)
82
69
  const isResizing = ref(false)
83
70
 
84
71
  const isFloating = computed(() => mode.value === 'floating')
85
72
 
86
- function clampPosition() {
87
- x.value = clamp(x.value, 0, viewportWidth.value - 80)
88
- y.value = clamp(y.value, 0, viewportHeight.value - 40)
89
- }
90
-
91
- const { x, y, isDragging } = useDraggable(panel, {
92
- handle,
93
- initialValue: { x: saved.value.rect.x, y: saved.value.rect.y },
94
- disabled: computed(() => !isFloating.value),
95
- preventDefault: true,
96
- onMove: clampPosition,
97
- onEnd: persist,
98
- })
99
-
100
73
  const style = computed<CSSProperties>(() => {
101
74
  if (mode.value === 'docked') return {}
102
75
  if (mode.value === 'minimized')
@@ -121,12 +94,7 @@ export function useFloatingWindow(
121
94
  if (!storageKey) return
122
95
  saved.value = {
123
96
  mode: mode.value,
124
- rect: {
125
- x: x.value,
126
- y: y.value,
127
- width: width.value,
128
- height: height.value,
129
- },
97
+ rect: { x: x.value, y: y.value, width: width.value, height: height.value },
130
98
  }
131
99
  }
132
100
 
@@ -135,55 +103,60 @@ export function useFloatingWindow(
135
103
  persist()
136
104
  }
137
105
 
138
- /** Park the window in the bottom-right corner, like a chat / mail composer. */
139
106
  function anchorBottomRight() {
140
- x.value = viewportWidth.value - width.value - EDGE_MARGIN
141
- y.value = viewportHeight.value - height.value - EDGE_MARGIN
142
- clampPosition()
107
+ x.value = clamp(viewportWidth.value - width.value - EDGE_MARGIN, 0, viewportWidth.value - width.value)
108
+ y.value = clamp(viewportHeight.value - height.value - EDGE_MARGIN, 0, viewportHeight.value - height.value)
143
109
  }
144
110
 
145
111
  const dock = () => setMode('docked')
146
112
  const minimize = () => setMode('minimized')
147
113
 
148
- /** Pop the window out, parked in the bottom-right at its floating size. */
149
114
  function float() {
150
115
  anchorBottomRight()
151
116
  setMode('floating')
152
117
  }
153
118
  const expandFromTray = float
154
119
 
155
- // Single detached window: when this one detaches it pins whichever window was
156
- // detached before; when it docks it clears itself from the active slot.
157
- watch(
158
- () => mode.value !== 'docked',
159
- (detached) => {
160
- if (detached) {
161
- if (activeDocker && activeDocker !== dock) activeDocker()
162
- setActiveDocker(dock)
163
- } else if (activeDocker === dock) {
164
- setActiveDocker(null)
165
- }
166
- },
167
- { immediate: true },
168
- )
169
- onScopeDispose(() => {
170
- if (activeDocker === dock) setActiveDocker(null)
171
- })
172
-
173
- /** Resize by a fixed delta, the keyboard path on the bottom-right corner. */
174
- function resizeBy(dx: number, dy: number) {
175
- width.value = clamp(width.value + dx, MIN_WIDTH, viewportWidth.value)
176
- height.value = clamp(height.value + dy, MIN_HEIGHT, viewportHeight.value)
177
- persist()
120
+ /**
121
+ * Begin a pointer drag from the title-bar handle. `setPointerCapture` routes
122
+ * all subsequent pointer events to the handle element, so the pointer never
123
+ * reaches background images or links — no native drag interference or jank.
124
+ */
125
+ function startDrag(event: PointerEvent) {
126
+ if (!isFloating.value) return
127
+ // Bail if the click originated on an interactive element inside the header
128
+ // (button, link, input…) so their own click handlers still fire.
129
+ if ((event.target as HTMLElement).closest('button, a, input, select, textarea, [role="button"]')) return
130
+ event.preventDefault()
131
+ // ponytail: best-effort; synthetic test events have no registered pointer id
132
+ try { ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) } catch {}
133
+ isDragging.value = true
134
+ const origin = { pointerX: event.clientX, pointerY: event.clientY, x: x.value, y: y.value }
135
+ const stopMove = useEventListener('pointermove', (e: PointerEvent) => {
136
+ x.value = clamp(origin.x + e.clientX - origin.pointerX, 0, viewportWidth.value - width.value)
137
+ y.value = clamp(origin.y + e.clientY - origin.pointerY, 0, viewportHeight.value - height.value)
138
+ })
139
+ const stopDrag = () => {
140
+ isDragging.value = false
141
+ persist()
142
+ stopMove()
143
+ stopUp()
144
+ stopCancel()
145
+ }
146
+ const stopUp = useEventListener('pointerup', stopDrag)
147
+ const stopCancel = useEventListener('pointercancel', stopDrag)
178
148
  }
179
149
 
180
150
  /**
181
151
  * Begin a pointer resize from an edge or corner. `dir` says which edges move;
182
- * it defaults to the bottom-right corner. Dragging a top/left edge also shifts
183
- * the panel's origin so the opposite edge stays anchored.
152
+ * dragging a top/left edge also shifts the panel's origin so the opposite
153
+ * edge stays anchored. Uses the same `setPointerCapture` pattern as `startDrag`.
184
154
  */
185
155
  function startResize(event: PointerEvent, dir: ResizeDir = { x: 1, y: 1 }) {
186
156
  isResizing.value = true
157
+ event.preventDefault()
158
+ // ponytail: best-effort; synthetic test events have no registered pointer id
159
+ try { ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) } catch {}
187
160
  const origin = {
188
161
  pointerX: event.clientX,
189
162
  pointerY: event.clientY,
@@ -202,28 +175,46 @@ export function useFloatingWindow(
202
175
  x.value = origin.x + origin.width - width.value
203
176
  }
204
177
  if (dir.y === 1) {
205
- height.value = clamp(
206
- origin.height + dy,
207
- MIN_HEIGHT,
208
- viewportHeight.value,
209
- )
178
+ height.value = clamp(origin.height + dy, MIN_HEIGHT, viewportHeight.value)
210
179
  } else if (dir.y === -1) {
211
- height.value = clamp(
212
- origin.height - dy,
213
- MIN_HEIGHT,
214
- viewportHeight.value,
215
- )
180
+ height.value = clamp(origin.height - dy, MIN_HEIGHT, viewportHeight.value)
216
181
  y.value = origin.y + origin.height - height.value
217
182
  }
218
183
  })
219
- const stopUp = useEventListener('pointerup', () => {
184
+ const stopResize = () => {
220
185
  isResizing.value = false
221
186
  persist()
222
187
  stopMove()
223
188
  stopUp()
224
- })
189
+ stopCancel()
190
+ }
191
+ const stopUp = useEventListener('pointerup', stopResize)
192
+ const stopCancel = useEventListener('pointercancel', stopResize)
193
+ }
194
+
195
+ /** Resize by a fixed delta — the keyboard path on the bottom-right corner. */
196
+ function resizeBy(dx: number, dy: number) {
197
+ width.value = clamp(width.value + dx, MIN_WIDTH, viewportWidth.value)
198
+ height.value = clamp(height.value + dy, MIN_HEIGHT, viewportHeight.value)
199
+ persist()
225
200
  }
226
201
 
202
+ watch(
203
+ () => mode.value !== 'docked',
204
+ (detached) => {
205
+ if (detached) {
206
+ if (activeDocker && activeDocker !== dock) activeDocker()
207
+ setActiveDocker(dock)
208
+ } else if (activeDocker === dock) {
209
+ setActiveDocker(null)
210
+ }
211
+ },
212
+ { immediate: true },
213
+ )
214
+ onScopeDispose(() => {
215
+ if (activeDocker === dock) setActiveDocker(null)
216
+ })
217
+
227
218
  return {
228
219
  mode,
229
220
  x,
@@ -238,6 +229,7 @@ export function useFloatingWindow(
238
229
  minimize,
239
230
  expandFromTray,
240
231
  setMode,
232
+ startDrag,
241
233
  startResize,
242
234
  resizeBy,
243
235
  }
@@ -0,0 +1,155 @@
1
+ import { defineComponent, h, ref } from 'vue'
2
+ import MultiEmailInput from './MultiEmailInput.vue'
3
+ import type { MultiEmailOption } from './types'
4
+
5
+ const members: MultiEmailOption[] = [
6
+ { label: 'Ada Lovelace', value: 'ada@example.com' },
7
+ { label: 'Grace Hopper', value: 'grace@example.com' },
8
+ ]
9
+
10
+ // A controlled host that surfaces the bound model + the last invalid email as
11
+ // text, so v-model sync and the `invalid` event are observable.
12
+ function host(
13
+ options: {
14
+ initial?: string[]
15
+ suggestions?: MultiEmailOption[]
16
+ label?: string
17
+ required?: boolean
18
+ error?: string
19
+ disabled?: boolean
20
+ } = {},
21
+ ) {
22
+ return defineComponent({
23
+ setup() {
24
+ const model = ref<string[]>(options.initial ?? [])
25
+ const invalid = ref('')
26
+ // Mutate the model the way a parent would (not via the component's UI),
27
+ // to prove a programmatic write syncs without surprising the host.
28
+ const setProgrammatically = () => (model.value = ['grace@example.com'])
29
+ return { model, invalid, setProgrammatically }
30
+ },
31
+ render() {
32
+ return [
33
+ h('span', { 'data-cy': 'model' }, this.model.join(',')),
34
+ h('span', { 'data-cy': 'invalid' }, this.invalid),
35
+ h(
36
+ 'button',
37
+ { 'data-cy': 'set-prog', onClick: this.setProgrammatically },
38
+ 'set',
39
+ ),
40
+ h(MultiEmailInput, {
41
+ modelValue: this.model,
42
+ 'onUpdate:modelValue': (v: string[]) => (this.model = v),
43
+ onInvalid: (v: string) => (this.invalid = v),
44
+ options: options.suggestions ?? members,
45
+ label: options.label,
46
+ required: options.required,
47
+ error: options.error,
48
+ disabled: options.disabled,
49
+ placeholder: 'Add email…',
50
+ }),
51
+ ]
52
+ },
53
+ })
54
+ }
55
+
56
+ describe('MultiEmailInput', () => {
57
+ it('renders the control and placeholder', () => {
58
+ cy.mount(host())
59
+ cy.get('[data-slot="control"]').should('exist')
60
+ cy.get('[data-slot="input"]')
61
+ .should('exist')
62
+ .and('have.attr', 'placeholder', 'Add email…')
63
+ })
64
+
65
+ it('forwards `id` and associates the label', () => {
66
+ cy.mount(host({ label: 'Invite by email', required: true }))
67
+ cy.get('label').should('contain.text', 'Invite by email')
68
+ cy.get('[data-slot="input"]')
69
+ .invoke('attr', 'id')
70
+ .then((id) => {
71
+ cy.get('label').should('have.attr', 'for', id)
72
+ })
73
+ })
74
+
75
+ it('picks a suggestion into a chip and syncs v-model', () => {
76
+ cy.mount(host())
77
+ cy.get('[data-slot="input"]').focus()
78
+ cy.get('[data-slot="item"]').contains('Ada Lovelace').click()
79
+ cy.get('[data-slot="tag"]').should('have.length', 1)
80
+ cy.get('[data-cy="model"]').should('have.text', 'ada@example.com')
81
+ })
82
+
83
+ it('commits a typed new address on Enter via the create row', () => {
84
+ cy.mount(host({ suggestions: [] }))
85
+ cy.get('[data-slot="input"]').type('new@person.com')
86
+ cy.get('[data-create="true"]').should('contain.text', 'new@person.com')
87
+ cy.get('[data-slot="input"]').type('{enter}')
88
+ cy.get('[data-cy="model"]').should('have.text', 'new@person.com')
89
+ cy.get('[data-slot="tag"]').should('have.length', 1)
90
+ })
91
+
92
+ it('commits the typed address on Enter even while suggestions are showing', () => {
93
+ // A valid, new address typed while the suggestion list is non-empty: Enter
94
+ // must add what was typed (create row is highlighted), not a suggestion.
95
+ cy.mount(host())
96
+ cy.get('[data-slot="input"]').type('new@person.com')
97
+ cy.get('[data-slot="item"]').should('have.length.greaterThan', 1)
98
+ cy.get('[data-slot="input"]').type('{enter}')
99
+ cy.get('[data-cy="model"]').should('have.text', 'new@person.com')
100
+ })
101
+
102
+ it('syncs a programmatic model change into chips', () => {
103
+ cy.mount(host())
104
+ cy.get('[data-cy="set-prog"]').click()
105
+ cy.get('[data-cy="model"]').should('have.text', 'grace@example.com')
106
+ cy.get('[data-slot="tag"]').should('have.length', 1)
107
+ })
108
+
109
+ it('applies a host class to the control when there is no labeling', () => {
110
+ cy.mount({
111
+ render: () => h(MultiEmailInput, { modelValue: [], class: 'w-full' }),
112
+ })
113
+ cy.get('[data-slot="control"]').should('have.class', 'w-full')
114
+ })
115
+
116
+ it('rejects an invalid typed address and adds no chip', () => {
117
+ cy.mount(host({ suggestions: [] }))
118
+ cy.get('[data-slot="input"]').type('not-an-email{enter}')
119
+ cy.get('[data-cy="invalid"]').should('have.text', 'not-an-email')
120
+ cy.get('[data-slot="tag"]').should('not.exist')
121
+ cy.get('[data-cy="model"]').should('have.text', '')
122
+ })
123
+
124
+ it('removes a chip via its delete button', () => {
125
+ cy.mount(host({ initial: ['ada@example.com'] }))
126
+ cy.get('[data-slot="tag"]').should('have.length', 1)
127
+ cy.get('[aria-label="Remove ada@example.com"]').click()
128
+ cy.get('[data-slot="tag"]').should('not.exist')
129
+ cy.get('[data-cy="model"]').should('have.text', '')
130
+ })
131
+
132
+ it('removes the last chip on Backspace from an empty input', () => {
133
+ cy.mount(host({ initial: ['ada@example.com', 'grace@example.com'] }))
134
+ cy.get('[data-slot="input"]').focus().type('{backspace}{backspace}')
135
+ cy.get('[data-cy="model"]').should('have.text', 'ada@example.com')
136
+ })
137
+
138
+ it('excludes already-selected emails from the suggestions', () => {
139
+ cy.mount(host({ initial: ['ada@example.com'] }))
140
+ cy.get('[data-slot="input"]').focus()
141
+ cy.get('[data-slot="item"]').should('have.length', 1)
142
+ cy.get('[data-slot="item"]').should('contain.text', 'Grace Hopper')
143
+ })
144
+
145
+ it('renders the error region and marks the input invalid', () => {
146
+ cy.mount(host({ error: 'Something went wrong' }))
147
+ cy.get('[data-slot="error"]').should('contain.text', 'Something went wrong')
148
+ cy.get('[data-slot="input"]').should('have.attr', 'aria-invalid', 'true')
149
+ })
150
+
151
+ it('does not open or accept input when disabled', () => {
152
+ cy.mount(host({ disabled: true }))
153
+ cy.get('[data-slot="input"]').should('have.attr', 'disabled')
154
+ })
155
+ })