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.
- package/experimental/FloatingWindow/FloatingWindow.cy.ts +24 -0
- package/experimental/FloatingWindow/FloatingWindow.vue +6 -4
- package/experimental/FloatingWindow/useFloatingWindow.ts +74 -82
- package/experimental/MultiEmailInput/MultiEmailInput.cy.ts +155 -0
- package/experimental/MultiEmailInput/MultiEmailInput.vue +472 -0
- package/experimental/MultiEmailInput/index.ts +17 -0
- package/experimental/MultiEmailInput/stories/AsyncSuggestions.vue +54 -0
- package/experimental/MultiEmailInput/stories/Basic.vue +18 -0
- package/experimental/MultiEmailInput/stories/CustomChip.vue +39 -0
- package/experimental/MultiEmailInput/stories/Labeling.vue +28 -0
- package/experimental/MultiEmailInput/types.ts +138 -0
- package/experimental/MultiEmailInput/utils.ts +60 -0
- package/experimental.ts +1 -0
- package/package.json +4 -1
- package/src/components/Autocomplete/Autocomplete.vue +3 -3
- package/src/components/Checkbox/Checkbox.cy.ts +10 -2
- package/src/components/Checkbox/Checkbox.vue +4 -18
- package/src/components/CodeEditor/CodeEditor.vue +0 -1
- package/src/components/Combobox/Combobox.vue +0 -1
- package/src/components/FormLabel.vue +5 -8
- package/src/components/InputLabeling/InputDescription.vue +1 -1
- package/src/components/InputLabeling/InputLabel.vue +21 -7
- package/src/components/MultiSelect/MultiSelect.vue +0 -1
- package/src/components/Popover/Popover.cy.ts +52 -0
- package/src/components/Popover/Popover.vue +17 -6
- package/src/components/Popover/types.ts +3 -0
- package/src/components/Rating/Rating.vue +3 -6
- package/src/components/ScrollArea/ScrollArea.api.md +59 -0
- package/src/components/ScrollArea/ScrollArea.vue +43 -0
- package/src/components/ScrollArea/ScrollBar.vue +83 -0
- package/src/components/ScrollArea/index.ts +2 -0
- package/src/components/Select/Select.cy.ts +16 -14
- package/src/components/Select/Select.vue +14 -15
- package/src/components/Select/stories/Example.vue +2 -1
- package/src/components/Select/utils.ts +2 -1
- package/src/components/SettingsDialog/SettingsBody.vue +23 -0
- package/src/components/SettingsDialog/SettingsContent.vue +5 -0
- package/src/components/SettingsDialog/SettingsDialog.api.md +273 -0
- package/src/components/SettingsDialog/SettingsDialog.cy.ts +126 -0
- package/src/components/SettingsDialog/SettingsDialog.md +43 -0
- package/src/components/SettingsDialog/SettingsDialog.vue +72 -0
- package/src/components/SettingsDialog/SettingsHeader.vue +36 -0
- package/src/components/SettingsDialog/SettingsNavGroup.vue +20 -0
- package/src/components/SettingsDialog/SettingsNavItem.vue +33 -0
- package/src/components/SettingsDialog/SettingsPanel.cy.ts +77 -0
- package/src/components/SettingsDialog/SettingsPanel.vue +26 -0
- package/src/components/SettingsDialog/SettingsRow.vue +52 -0
- package/src/components/SettingsDialog/SettingsSidebar.vue +16 -0
- package/src/components/SettingsDialog/index.ts +10 -0
- package/src/components/SettingsDialog/stories/Default.vue +99 -0
- package/src/components/SettingsDialog/stories/PanelBasic.vue +52 -0
- package/src/components/SettingsDialog/stories/panels/NotificationsPanel.vue +57 -0
- package/src/components/SettingsDialog/stories/panels/PreferencesPanel.vue +57 -0
- package/src/components/SettingsDialog/stories/panels/ProfilePanel.vue +38 -0
- package/src/components/SettingsDialog/stories/panels/UsersPanel.vue +83 -0
- package/src/components/SettingsDialog/types.ts +21 -0
- package/src/components/Slider/Slider.vue +0 -1
- package/src/components/Switch/Switch.vue +4 -20
- package/src/components/TextEditor/components/CodeBlockComponent.vue +4 -194
- package/src/components/TextEditor/components/MediaNodeView.vue +1 -1
- package/src/components/TextEditor/extensions/iframe/InsertIframe.vue +1 -1
- package/src/components/TextEditor/hljs-github.css +197 -0
- package/src/components/TextInput/TextInput.cy.ts +6 -1
- package/src/components/TextInput/TextInput.vue +7 -11
- package/src/components/Textarea/Textarea.vue +7 -11
- package/src/components/shared/selection/utils.ts +1 -1
- package/src/index.ts +2 -0
- package/src/molecules/editor/extensions/iframe/IframeInsertDialog.vue +1 -1
- 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-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
56
|
-
* `
|
|
57
|
-
*
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
{
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
*
|
|
183
|
-
*
|
|
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
|
|
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
|
+
})
|