frappe-ui 1.0.0-beta.0 → 1.0.0-beta.2
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/frappe/Link/Link.api.md +114 -0
- package/frappe/Link/Link.cy.ts +215 -0
- package/frappe/Link/Link.md +38 -0
- package/frappe/Link/Link.vue +101 -42
- package/frappe/Link/index.ts +1 -1
- package/frappe/Link/stories/Creatable.vue +96 -0
- package/frappe/Link/stories/Filters.vue +67 -0
- package/frappe/Link/stories/Labeling.vue +32 -0
- package/frappe/Link/stories/MemberPicker.vue +37 -0
- package/frappe/Link/stories/Simple.vue +21 -0
- package/frappe/Link/stories/Suffix.vue +118 -0
- package/frappe/Link/stories/_mock.ts +151 -0
- package/frappe/Link/types.ts +20 -8
- package/package.json +15 -4
- package/src/components/Combobox/Combobox.api.md +2 -2
- package/src/components/Combobox/Combobox.md +5 -0
- package/src/components/Combobox/Combobox.vue +45 -37
- package/src/components/Combobox/ComboboxResults.vue +0 -8
- package/src/components/Combobox/stories/Footer.vue +67 -0
- package/src/components/Combobox/types.ts +21 -2
- package/src/components/ListView/ListGroupHeader.vue +1 -1
- package/src/components/Rating/Rating.api.md +9 -2
- package/src/components/Rating/Rating.cy.ts +13 -5
- package/src/components/Rating/Rating.md +13 -57
- package/src/components/Rating/Rating.vue +33 -30
- package/src/components/Rating/stories/CustomIcon.vue +16 -3
- package/src/components/Rating/stories/CustomSlot.vue +1 -1
- package/src/components/Rating/stories/Labeling.vue +1 -1
- package/src/components/Rating/stories/States.vue +1 -1
- package/src/components/Rating/types.ts +7 -1
- package/src/components/Select/Select.api.md +2 -2
- package/src/components/Select/Select.md +5 -0
- package/src/components/Select/Select.vue +28 -19
- package/src/components/Select/stories/Footer.vue +63 -0
- package/src/components/Select/types.ts +14 -2
- package/src/components/TextEditor/TextEditor.vue +12 -0
- package/src/components/Toast/Toast.md +5 -1
- package/src/components/Toast/ToastProvider.vue +1 -1
- package/src/components/Toast/stories/Async.vue +28 -7
- package/src/components/Toast/toast.ts +0 -42
- package/src/molecules/editor/Editor.cy.ts +141 -0
- package/src/molecules/editor/Editor.test.ts +265 -0
- package/src/molecules/editor/Editor.vue +108 -0
- package/src/molecules/editor/EditorBubbleMenu.vue +45 -0
- package/src/molecules/editor/EditorContent.vue +105 -0
- package/src/molecules/editor/EditorFixedMenu.vue +20 -0
- package/src/molecules/editor/EditorFloatingMenu.vue +45 -0
- package/src/molecules/editor/MenuItems.vue +167 -0
- package/src/molecules/editor/SuggestionExtension.ts +53 -0
- package/src/molecules/editor/commands.ts +185 -0
- package/src/molecules/editor/components/ImageViewerModal.vue +238 -0
- package/src/molecules/editor/components/InsertImage.vue +54 -0
- package/src/molecules/editor/components/InsertVideo.vue +14 -0
- package/src/molecules/editor/components/MediaNodeView.vue +254 -0
- package/src/molecules/editor/components/MediaToolbar.vue +107 -0
- package/src/molecules/editor/components/font-color/ColorSwatchGrid.vue +45 -0
- package/src/molecules/editor/components/font-color/fontColorController.ts +76 -0
- package/src/molecules/editor/components/font-color/swatches.ts +92 -0
- package/src/molecules/editor/components/image-viewer/ImageViewerControlsBar.vue +123 -0
- package/src/molecules/editor/components/image-viewer/imageViewerDownload.ts +46 -0
- package/src/molecules/editor/components/image-viewer/imageViewerKeymap.ts +61 -0
- package/src/molecules/editor/components/media-node-view-controller.ts +108 -0
- package/src/molecules/editor/components/media-node-view-utils.test.ts +71 -0
- package/src/molecules/editor/components/media-node-view-utils.ts +77 -0
- package/src/molecules/editor/composables/useControlsAutoHide.ts +66 -0
- package/src/molecules/editor/composables/useElementSize.ts +50 -0
- package/src/molecules/editor/composables/useFloatingPopup.ts +122 -0
- package/src/molecules/editor/composables/useFullscreen.ts +47 -0
- package/src/molecules/editor/composables/useNamedColorState.ts +74 -0
- package/src/molecules/editor/composables/useNodeViewEditable.ts +35 -0
- package/src/molecules/editor/composables/useNodeViewResize.test.ts +137 -0
- package/src/molecules/editor/composables/useNodeViewResize.ts +132 -0
- package/src/molecules/editor/composables/useObjectUrl.ts +74 -0
- package/src/molecules/editor/composables/useScopedFileDrop.ts +86 -0
- package/src/molecules/editor/composables/useScrollContainer.ts +34 -0
- package/src/molecules/editor/composables/useSuggestionList.ts +66 -0
- package/src/molecules/editor/composables/useTocActiveHeading.ts +96 -0
- package/src/molecules/editor/composables/useTocAnchors.ts +83 -0
- package/src/molecules/editor/editor-context.ts +37 -0
- package/src/molecules/editor/extensions/code-block/CodeBlockComponent.css +220 -0
- package/src/molecules/editor/extensions/code-block/CodeBlockComponent.vue +55 -0
- package/src/molecules/editor/extensions/code-block/code-block-indent.test.ts +44 -0
- package/src/molecules/editor/extensions/code-block/code-block-indent.ts +60 -0
- package/src/molecules/editor/extensions/code-block/code-block.ts +85 -0
- package/src/molecules/editor/extensions/code-block.ts +4 -0
- package/src/molecules/editor/extensions/color/color-extension.ts +112 -0
- package/src/molecules/editor/extensions/color/color-styles.css +28 -0
- package/src/molecules/editor/extensions/color/index.ts +2 -0
- package/src/molecules/editor/extensions/content-paste/content-paste-extension.ts +109 -0
- package/src/molecules/editor/extensions/content-paste/index.ts +4 -0
- package/src/molecules/editor/extensions/content-paste/media-src-utils.ts +53 -0
- package/src/molecules/editor/extensions/content-paste/paste-html-utils.ts +71 -0
- package/src/molecules/editor/extensions/content-paste/paste-image-controller.test.ts +86 -0
- package/src/molecules/editor/extensions/content-paste/paste-image-controller.ts +95 -0
- package/src/molecules/editor/extensions/content-paste/paste-markdown-utils.ts +20 -0
- package/src/molecules/editor/extensions/copy-styles/copy-styles-extension.ts +146 -0
- package/src/molecules/editor/extensions/copy-styles/index.ts +5 -0
- package/src/molecules/editor/extensions/copy-styles/style-clipboard-utils.ts +134 -0
- package/src/molecules/editor/extensions/emoji/EmojiList.vue +52 -0
- package/src/molecules/editor/extensions/emoji/emoji-extension.ts +54 -0
- package/src/molecules/editor/extensions/emoji/emojis.json +7422 -0
- package/src/molecules/editor/extensions/heading/heading-ids.ts +92 -0
- package/src/molecules/editor/extensions/heading/heading.ts +20 -0
- package/src/molecules/editor/extensions/highlight/highlight-extension.ts +214 -0
- package/src/molecules/editor/extensions/highlight/highlight-styles.css +34 -0
- package/src/molecules/editor/extensions/highlight/index.ts +2 -0
- package/src/molecules/editor/extensions/iframe/IframeInsertDialog.vue +79 -0
- package/src/molecules/editor/extensions/iframe/IframeNodeView.vue +218 -0
- package/src/molecules/editor/extensions/iframe/iframe-allowlist.ts +79 -0
- package/src/molecules/editor/extensions/iframe/iframe-commands.ts +88 -0
- package/src/molecules/editor/extensions/iframe/iframe-embed-utils.ts +247 -0
- package/src/molecules/editor/extensions/iframe/iframe-extension.test.ts +72 -0
- package/src/molecules/editor/extensions/iframe/iframe-extension.ts +180 -0
- package/src/molecules/editor/extensions/iframe/iframe-paste-handler.ts +90 -0
- package/src/molecules/editor/extensions/iframe/iframeInsertDialogController.ts +44 -0
- package/src/molecules/editor/extensions/iframe/index.ts +22 -0
- package/src/molecules/editor/extensions/iframe/parseIframeEmbed.ts +49 -0
- package/src/molecules/editor/extensions/iframe/useIframeDialog.ts +127 -0
- package/src/molecules/editor/extensions/image/image-engine.ts +22 -0
- package/src/molecules/editor/extensions/image/image-extension.ts +278 -0
- package/src/molecules/editor/extensions/image/index.ts +5 -0
- package/src/molecules/editor/extensions/image-group/ImageGroupGrid.vue +82 -0
- package/src/molecules/editor/extensions/image-group/ImageGroupGridCell.vue +126 -0
- package/src/molecules/editor/extensions/image-group/ImageGroupNodeView.vue +169 -0
- package/src/molecules/editor/extensions/image-group/ImageGroupUploadDialog.vue +294 -0
- package/src/molecules/editor/extensions/image-group/image-group-commands.ts +187 -0
- package/src/molecules/editor/extensions/image-group/image-group-extension.ts +108 -0
- package/src/molecules/editor/extensions/image-group/image-group-utils.test.ts +99 -0
- package/src/molecules/editor/extensions/image-group/image-group-utils.ts +89 -0
- package/src/molecules/editor/extensions/image-group/imageGroupDialogController.ts +47 -0
- package/src/molecules/editor/extensions/image-group/index.ts +6 -0
- package/src/molecules/editor/extensions/image-group/useImageGroupDialog.ts +230 -0
- package/src/molecules/editor/extensions/image-group/useStrayDropGuard.ts +34 -0
- package/src/molecules/editor/extensions/image-viewer/collectImages.ts +39 -0
- package/src/molecules/editor/extensions/image-viewer/image-viewer-extension.ts +42 -0
- package/src/molecules/editor/extensions/image-viewer/imageViewerController.ts +73 -0
- package/src/molecules/editor/extensions/image-viewer/imageViewerStyle.ts +53 -0
- package/src/molecules/editor/extensions/image-viewer/index.ts +2 -0
- package/src/molecules/editor/extensions/link/InsertLink.vue +13 -0
- package/src/molecules/editor/extensions/link/LinkEditorPopup.vue +110 -0
- package/src/molecules/editor/extensions/link/clear-link-on-boundary-plugin.ts +65 -0
- package/src/molecules/editor/extensions/link/index.ts +2 -0
- package/src/molecules/editor/extensions/link/link-click-plugin.ts +58 -0
- package/src/molecules/editor/extensions/link/link-commands.ts +148 -0
- package/src/molecules/editor/extensions/link/link-extension.ts +58 -0
- package/src/molecules/editor/extensions/link/link-paste-plugin.ts +59 -0
- package/src/molecules/editor/extensions/link/link-popup-controller.ts +106 -0
- package/src/molecules/editor/extensions/link/link-shortcut-plugin.ts +42 -0
- package/src/molecules/editor/extensions/mention/index.ts +1 -0
- package/src/molecules/editor/extensions/mention/mention-extension.ts +198 -0
- package/src/molecules/editor/extensions/mention/style.css +11 -0
- package/src/molecules/editor/extensions/shared/color-palette.test.ts +136 -0
- package/src/molecules/editor/extensions/shared/color-palette.ts +118 -0
- package/src/molecules/editor/extensions/shared/color-parse.ts +137 -0
- package/src/molecules/editor/extensions/shared/color-style.ts +119 -0
- package/src/molecules/editor/extensions/shared/color-utils.ts +13 -0
- package/src/molecules/editor/extensions/shared/file-picker.ts +16 -0
- package/src/molecules/editor/extensions/shared/heading-scope.ts +103 -0
- package/src/molecules/editor/extensions/shared/heading-tree-utils.ts +65 -0
- package/src/molecules/editor/extensions/shared/lowlight-languages.ts +42 -0
- package/src/molecules/editor/extensions/shared/media-dimensions.ts +60 -0
- package/src/molecules/editor/extensions/shared/media-node-ops.ts +144 -0
- package/src/molecules/editor/extensions/shared/media-plugin.ts +166 -0
- package/src/molecules/editor/extensions/shared/media-upload-engine.test.ts +172 -0
- package/src/molecules/editor/extensions/shared/media-upload-engine.ts +288 -0
- package/src/molecules/editor/extensions/shared/media-upload-state.ts +36 -0
- package/src/molecules/editor/extensions/shared/media-upload-types.ts +105 -0
- package/src/molecules/editor/extensions/shared/node-view.test.ts +43 -0
- package/src/molecules/editor/extensions/shared/node-view.ts +98 -0
- package/src/molecules/editor/extensions/shared/suggestion-helpers.ts +63 -0
- package/src/molecules/editor/extensions/shared/suggestion-renderer.ts +146 -0
- package/src/molecules/editor/extensions/shared/suggestion-types.ts +38 -0
- package/src/molecules/editor/extensions/shared/toggle-code-shortcut.ts +14 -0
- package/src/molecules/editor/extensions/shared/upload-id.ts +12 -0
- package/src/molecules/editor/extensions/shared/upload-types.ts +26 -0
- package/src/molecules/editor/extensions/shared/url-safety.ts +102 -0
- package/src/molecules/editor/extensions/slash-commands/SlashCommandsList.vue +55 -0
- package/src/molecules/editor/extensions/slash-commands/slash-commands-extension.ts +132 -0
- package/src/molecules/editor/extensions/suggestion/SuggestionList.vue +106 -0
- package/src/molecules/editor/extensions/suggestion/SuggestionListItem.vue +46 -0
- package/src/molecules/editor/extensions/suggestion/createSuggestionExtension.ts +84 -0
- package/src/molecules/editor/extensions/suggestion/index.ts +6 -0
- package/src/molecules/editor/extensions/tag/index.ts +7 -0
- package/src/molecules/editor/extensions/tag/tag-extension.ts +200 -0
- package/src/molecules/editor/extensions/toc-node/TocItem.vue +49 -0
- package/src/molecules/editor/extensions/toc-node/TocNodeView.vue +103 -0
- package/src/molecules/editor/extensions/toc-node/index.ts +3 -0
- package/src/molecules/editor/extensions/toc-node/toc-node-extension.ts +61 -0
- package/src/molecules/editor/extensions/toc-node/toc-render.ts +67 -0
- package/src/molecules/editor/extensions/toc-node/toc-scroll-controller.ts +89 -0
- package/src/molecules/editor/extensions/video/index.ts +5 -0
- package/src/molecules/editor/extensions/video/video-config.ts +28 -0
- package/src/molecules/editor/extensions/video/video-extension.ts +240 -0
- package/src/molecules/editor/extensions.ts +145 -0
- package/src/molecules/editor/index.ts +34 -0
- package/src/molecules/editor/kits.test.ts +148 -0
- package/src/molecules/editor/kits.ts +281 -0
- package/src/molecules/editor/menu.test.ts +294 -0
- package/src/molecules/editor/menu.ts +225 -0
- package/src/molecules/editor/stories/Comment.vue +105 -0
- package/src/molecules/editor/stories/Inline.vue +28 -0
- package/src/molecules/editor/stories/Primitives.vue +73 -0
- package/src/molecules/editor/stories/RichText.vue +129 -0
- package/src/molecules/editor/style.css +152 -0
- package/src/molecules/editor/useEditor.test.ts +237 -0
- package/src/molecules/editor/useEditor.ts +135 -0
- package/src/resources/resources.js +11 -1
- package/src/resources/resources.test.ts +45 -0
- package/src/utils/request.js +1 -0
- package/tailwind/plugin.js +8 -1
- package/tailwind/preset.js +9 -0
- package/src/components/Rating/stories/Clearable.vue +0 -29
- package/src/components/Rating/stories/CustomColor.vue +0 -47
- package/src/components/Rating/stories/HalfStepCustomColor.vue +0 -27
- package/src/components/Rating/stories/LegacyRatingFrom.vue +0 -13
- package/src/components/Rating/stories/Max.vue +0 -10
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<!-- Auto Generated by scripts/propsgen.ts -->
|
|
2
|
+
<script setup>
|
|
3
|
+
import PropsTable from '@/components/Docs/PropsTable.vue'
|
|
4
|
+
import SlotsTable from '@/components/Docs/SlotsTable.vue'
|
|
5
|
+
import EmitsTable from '@/components/Docs/EmitsTable.vue'
|
|
6
|
+
|
|
7
|
+
const propsData = [
|
|
8
|
+
{
|
|
9
|
+
name: 'doctype',
|
|
10
|
+
description: '',
|
|
11
|
+
required: true,
|
|
12
|
+
type: 'string'
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'filters',
|
|
16
|
+
description: '',
|
|
17
|
+
required: false,
|
|
18
|
+
type: 'Record<string, unknown>',
|
|
19
|
+
default: '{}'
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'creatable',
|
|
23
|
+
description: '',
|
|
24
|
+
required: false,
|
|
25
|
+
type: 'boolean',
|
|
26
|
+
default: 'false'
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'disabled',
|
|
30
|
+
description: '',
|
|
31
|
+
required: false,
|
|
32
|
+
type: 'boolean',
|
|
33
|
+
default: 'false'
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'label',
|
|
37
|
+
description: 'Label rendered above (or beside, for binary controls) the input.',
|
|
38
|
+
required: false,
|
|
39
|
+
type: 'string'
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'description',
|
|
43
|
+
description: 'Helper text rendered below the input.\nHidden when `error` is set.',
|
|
44
|
+
required: false,
|
|
45
|
+
type: 'string'
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'error',
|
|
49
|
+
description: 'Error message rendered below the input. When set, the control receives\n`aria-invalid="true"` and `data-state="invalid"`. May be either a string\nor an `Error` object whose `messages?: string[]` is rendered as stacked\nlines (with `Error.message` as the fallback).',
|
|
50
|
+
required: false,
|
|
51
|
+
type: 'string | FrappeUIError'
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'required',
|
|
55
|
+
description: 'Marks the field as required. Renders an asterisk next to the label and\nforwards `required` / `aria-required` to the underlying control.',
|
|
56
|
+
required: false,
|
|
57
|
+
type: 'boolean'
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'id',
|
|
61
|
+
description: 'HTML id of the underlying control. Auto-generated via `useId()` if omitted.',
|
|
62
|
+
required: false,
|
|
63
|
+
type: 'string'
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'modelValue',
|
|
67
|
+
description: '',
|
|
68
|
+
required: false,
|
|
69
|
+
type: 'string | null',
|
|
70
|
+
default: 'null'
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'open',
|
|
74
|
+
description: '',
|
|
75
|
+
required: false,
|
|
76
|
+
type: 'boolean',
|
|
77
|
+
default: 'false'
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
const slotsData = [
|
|
82
|
+
{
|
|
83
|
+
name: 'suffix',
|
|
84
|
+
description: '',
|
|
85
|
+
type: '{ open: boolean; disabled: boolean; query: string; selectedOption: ComboboxSelectableOption | null; '
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
const emitsData = [
|
|
90
|
+
{
|
|
91
|
+
name: 'update:modelValue',
|
|
92
|
+
description: 'Fired when the model value changes.',
|
|
93
|
+
type: 'unknown[]'
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'update:open',
|
|
97
|
+
description: 'Fired when the open state changes.',
|
|
98
|
+
type: 'unknown[]'
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'create',
|
|
102
|
+
description: '',
|
|
103
|
+
type: '[query: string]'
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
</script>
|
|
107
|
+
## API Reference
|
|
108
|
+
|
|
109
|
+
<PropsTable name="Link" :data="propsData"/>
|
|
110
|
+
|
|
111
|
+
<SlotsTable :data="slotsData"/>
|
|
112
|
+
|
|
113
|
+
<EmitsTable :data="emitsData"/>
|
|
114
|
+
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { defineComponent, h, ref } from 'vue'
|
|
2
|
+
import Link from './Link.vue'
|
|
3
|
+
|
|
4
|
+
const users = [
|
|
5
|
+
{ value: 'alice', label: 'Alice' },
|
|
6
|
+
{ value: 'bob', label: 'Bob' },
|
|
7
|
+
{ value: 'carol', label: 'Carol' },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
function stubSearchLink(rows = users) {
|
|
11
|
+
cy.intercept('POST', '/api/method/frappe.desk.search.search_link', {
|
|
12
|
+
statusCode: 200,
|
|
13
|
+
body: { message: rows },
|
|
14
|
+
}).as('searchLink')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('Link', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
stubSearchLink()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('rendering', () => {
|
|
23
|
+
it('emits data-slot="link" on the root wrapper', () => {
|
|
24
|
+
cy.mount(Link, { props: { doctype: 'User' } })
|
|
25
|
+
cy.get('[data-slot="link"]').should('exist')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('forwards id/label/description/required to the underlying Combobox (P5)', () => {
|
|
29
|
+
cy.mount(Link, {
|
|
30
|
+
props: {
|
|
31
|
+
doctype: 'User',
|
|
32
|
+
id: 'user-picker',
|
|
33
|
+
label: 'User',
|
|
34
|
+
description: 'Pick a user',
|
|
35
|
+
required: true,
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
cy.get('label[for="user-picker"]').should('contain.text', 'User')
|
|
40
|
+
cy.get('[role="combobox"]#user-picker').should(
|
|
41
|
+
'have.attr',
|
|
42
|
+
'aria-required',
|
|
43
|
+
'true',
|
|
44
|
+
)
|
|
45
|
+
cy.contains('Pick a user').should('exist')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('forwards `error` and wires aria-invalid', () => {
|
|
49
|
+
cy.mount(Link, {
|
|
50
|
+
props: {
|
|
51
|
+
doctype: 'User',
|
|
52
|
+
id: 'user-picker',
|
|
53
|
+
error: 'Boom',
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
cy.get('[role="combobox"]#user-picker').should(
|
|
57
|
+
'have.attr',
|
|
58
|
+
'aria-invalid',
|
|
59
|
+
'true',
|
|
60
|
+
)
|
|
61
|
+
cy.contains('Boom').should('exist')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('v-model', () => {
|
|
66
|
+
it('round-trips a selection', () => {
|
|
67
|
+
cy.mount(Link, {
|
|
68
|
+
props: {
|
|
69
|
+
doctype: 'User',
|
|
70
|
+
'onUpdate:modelValue': cy.spy().as('onUpdate'),
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
cy.wait('@searchLink')
|
|
75
|
+
cy.get('[role="combobox"]').click()
|
|
76
|
+
cy.contains('[role="option"]', 'Alice').click()
|
|
77
|
+
cy.get('@onUpdate').should('have.been.calledWith', 'alice')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('clear sets the model to null (never the empty string)', () => {
|
|
81
|
+
const Host = defineComponent({
|
|
82
|
+
setup() {
|
|
83
|
+
const value = ref<string | null>('alice')
|
|
84
|
+
return { value }
|
|
85
|
+
},
|
|
86
|
+
render() {
|
|
87
|
+
return h(Link, {
|
|
88
|
+
doctype: 'User',
|
|
89
|
+
modelValue: this.value,
|
|
90
|
+
'onUpdate:modelValue': (v: string | null) => (this.value = v),
|
|
91
|
+
})
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
cy.mount(Host).then(({ component }) => {
|
|
96
|
+
const host = component as unknown as { value: string | null }
|
|
97
|
+
cy.get('[data-slot="clear"]').click({ force: true })
|
|
98
|
+
cy.then(() => expect(host.value).to.equal(null))
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('default clear', () => {
|
|
104
|
+
it('renders a clear button when value is set and not required', () => {
|
|
105
|
+
cy.mount(Link, {
|
|
106
|
+
props: {
|
|
107
|
+
doctype: 'User',
|
|
108
|
+
modelValue: 'alice',
|
|
109
|
+
'onUpdate:modelValue': cy.spy().as('onUpdate'),
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
cy.get('[data-slot="clear"]').should('exist').click({ force: true })
|
|
113
|
+
cy.get('@onUpdate').should('have.been.calledWith', null)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('suppresses the clear button when required is true', () => {
|
|
117
|
+
cy.mount(Link, {
|
|
118
|
+
props: { doctype: 'User', modelValue: 'alice', required: true },
|
|
119
|
+
})
|
|
120
|
+
cy.get('[data-slot="clear"]').should('not.exist')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('suppresses the clear button when modelValue is empty', () => {
|
|
124
|
+
cy.mount(Link, { props: { doctype: 'User' } })
|
|
125
|
+
cy.get('[data-slot="clear"]').should('not.exist')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('creatable', () => {
|
|
130
|
+
it('does not render the Create row by default', () => {
|
|
131
|
+
cy.mount(Link, { props: { doctype: 'User' } })
|
|
132
|
+
cy.get('[role="combobox"]').click().type('zed')
|
|
133
|
+
cy.contains('[role="option"]', 'Create').should('not.exist')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('shows the Create row only when query is non-empty', () => {
|
|
137
|
+
cy.mount(Link, { props: { doctype: 'User', creatable: true } })
|
|
138
|
+
cy.get('[role="combobox"]').click()
|
|
139
|
+
cy.contains('[role="option"]', 'Create').should('not.exist')
|
|
140
|
+
cy.get('[role="combobox"]').type('zed')
|
|
141
|
+
cy.contains('[role="option"]', 'Create').should('contain.text', 'zed')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('emits @create with just the query (one argument)', () => {
|
|
145
|
+
cy.mount(Link, {
|
|
146
|
+
props: {
|
|
147
|
+
doctype: 'User',
|
|
148
|
+
creatable: true,
|
|
149
|
+
onCreate: cy.spy().as('onCreate'),
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
cy.get('[role="combobox"]').click().type('zed')
|
|
153
|
+
cy.contains('[role="option"]', 'Create').click()
|
|
154
|
+
cy.get('@onCreate').should('have.been.calledOnce')
|
|
155
|
+
cy.get('@onCreate').should('have.been.calledWith', 'zed')
|
|
156
|
+
cy.get('@onCreate').then((spy) => {
|
|
157
|
+
// Spec: @create is one-arg. Guard against the old (query, close) shape.
|
|
158
|
+
const callArgs = (spy as unknown as sinon.SinonSpy).getCall(0).args
|
|
159
|
+
expect(callArgs).to.have.length(1)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('v-model:open', () => {
|
|
165
|
+
it('emits update:open when the popover toggles', () => {
|
|
166
|
+
cy.mount(Link, {
|
|
167
|
+
props: {
|
|
168
|
+
doctype: 'User',
|
|
169
|
+
'onUpdate:open': cy.spy().as('onOpen'),
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
cy.get('[role="combobox"]').click()
|
|
173
|
+
cy.get('@onOpen').should('have.been.calledWith', true)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('exposed API', () => {
|
|
178
|
+
it('exposes a zero-arg reload() that re-fetches options', () => {
|
|
179
|
+
const Host = defineComponent({
|
|
180
|
+
setup() {
|
|
181
|
+
const link = ref<{ reload: () => void } | null>(null)
|
|
182
|
+
return { link }
|
|
183
|
+
},
|
|
184
|
+
render() {
|
|
185
|
+
return h(Link, { doctype: 'User', ref: 'link' })
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
cy.mount(Host).then(({ component }) => {
|
|
190
|
+
cy.wait('@searchLink')
|
|
191
|
+
cy.then(() => {
|
|
192
|
+
const exposed = (component as unknown as { link: { reload: () => void } }).link
|
|
193
|
+
expect(exposed.reload).to.be.a('function')
|
|
194
|
+
expect(exposed.reload.length).to.equal(0)
|
|
195
|
+
exposed.reload()
|
|
196
|
+
})
|
|
197
|
+
cy.wait('@searchLink')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('#suffix slot', () => {
|
|
203
|
+
it('replaces the default clear button', () => {
|
|
204
|
+
cy.mount(Link, {
|
|
205
|
+
props: { doctype: 'User', modelValue: 'alice' },
|
|
206
|
+
slots: {
|
|
207
|
+
suffix: () =>
|
|
208
|
+
h('button', { 'data-slot': 'redirect', type: 'button' }, 'Go'),
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
cy.get('[data-slot="clear"]').should('not.exist')
|
|
212
|
+
cy.get('[data-slot="redirect"]').should('exist')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Link
|
|
2
|
+
|
|
3
|
+
A single-record picker for a Frappe doctype. Composes `Combobox` and fetches its options directly from `frappe.desk.search.search_link` — pick a doctype, and the rest works.
|
|
4
|
+
|
|
5
|
+
## Simple
|
|
6
|
+
A bare picker. `v-model` carries the selected record's primary key (a string), or `null` when nothing is selected.
|
|
7
|
+
|
|
8
|
+
<ComponentPreview csr="true" name="Link-Simple" layout="stacked" />
|
|
9
|
+
|
|
10
|
+
## Labeling, Description, Error, Required
|
|
11
|
+
`Link` accepts the standard input labeling props — `label`, `description`, `error`, `required` — and forwards them to the underlying `Combobox`, so ARIA wiring (`aria-required`, `aria-invalid`, `aria-errormessage`) and the InputLabel chrome render identically to a standalone `Combobox`.
|
|
12
|
+
|
|
13
|
+
<ComponentPreview csr="true" name="Link-Labeling" />
|
|
14
|
+
|
|
15
|
+
## Filters
|
|
16
|
+
The `filters` prop is a `Record<string, unknown>` passed straight through to `search_link`. Frappe's underlying endpoint also accepts list-form and SQL-string filters, but the component boundary intentionally narrows to the dict form — it covers every CRM call site without leaking backend serialization into the public API. Reactive — changing `filters` triggers a refetch. The `#footer` slot is the canonical home for a filter-status row (or any non-selectable popover affordance) and renders below both the options list and the `creatable` create-new row.
|
|
17
|
+
|
|
18
|
+
<ComponentPreview csr="true" name="Link-Filters" />
|
|
19
|
+
|
|
20
|
+
## Creatable
|
|
21
|
+
`creatable: true` injects a "Create" row at the bottom of the popover, visible only when the user has typed. Clicking it emits `@create` with the typed query — the consumer owns the create flow (typically a dialog) and assigns the freshly-created record back to `v-model` on success. To replace the default create row markup (icon, helper text, copy), supply the `#item-create` slot — scope is `{ query }`.
|
|
22
|
+
|
|
23
|
+
<ComponentPreview csr="true" name="Link-Creatable" />
|
|
24
|
+
|
|
25
|
+
## Suffix
|
|
26
|
+
By default, Link renders a clear button in the suffix slot when `modelValue` is non-null and `required: false`. To replace it with your own affordance — an "Open record" link, an "Edit" button — supply `#suffix`. The slot fully takes over; if you still want a clear button alongside your action, render one yourself. To dismiss the options dropdown when your action takes over (e.g. opens a dialog), bind `v-model:open` and set it to `false`.
|
|
27
|
+
|
|
28
|
+
<ComponentPreview csr="true" name="Link-Suffix" />
|
|
29
|
+
|
|
30
|
+
## Member Picker
|
|
31
|
+
`Link` forwards every `Combobox` per-row slot — `#item-prefix`, `#item-label`, `#item-suffix`, `#item` — so a doctype picker can render an avatar, role, or any other contextual chrome without dropping down to `<Combobox>` directly.
|
|
32
|
+
|
|
33
|
+
<ComponentPreview csr="true" name="Link-MemberPicker" />
|
|
34
|
+
|
|
35
|
+
## Combobox passthrough
|
|
36
|
+
Link composes `Combobox` and forwards unrecognized props via `$attrs` and all non-overridden slots. Anything in the [`Combobox` API](../../docs/components/combobox) that Link doesn't claim itself (`options`, `loading`, and the `#suffix` / `#item-create` slots which have Link-specific defaults) reaches the underlying component unchanged.
|
|
37
|
+
|
|
38
|
+
<!-- @include: ./Link.api.md -->
|
package/frappe/Link/Link.vue
CHANGED
|
@@ -1,52 +1,99 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
3
|
-
<FormLabel v-if="label" :label="label" size="sm" :required="required" />
|
|
2
|
+
<div data-slot="link" class="contents">
|
|
4
3
|
<Combobox
|
|
4
|
+
ref="comboboxRef"
|
|
5
|
+
v-bind="$attrs"
|
|
5
6
|
v-model="model"
|
|
6
|
-
:
|
|
7
|
+
v-model:open="open"
|
|
8
|
+
class="group !gap-1"
|
|
9
|
+
:label="label"
|
|
10
|
+
:description="description"
|
|
11
|
+
:error="error"
|
|
12
|
+
:required="required"
|
|
13
|
+
:id="id"
|
|
7
14
|
:options="linkOptions"
|
|
8
|
-
|
|
15
|
+
:disabled="disabled"
|
|
16
|
+
:placeholder="placeholder ?? `Search ${doctype.toLowerCase()}`"
|
|
17
|
+
:loading="options.loading && !options.data"
|
|
18
|
+
@update:query="handleInputChange"
|
|
9
19
|
@focus="() => loadOptions('')"
|
|
10
|
-
:open-on-focus="true"
|
|
11
|
-
v-bind="attrsWithoutClassStyle"
|
|
12
|
-
:variant="props.variant"
|
|
13
20
|
>
|
|
14
|
-
<template
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
<template
|
|
22
|
+
v-for="(_, name) in forwardedSlots"
|
|
23
|
+
#[name]="slotProps"
|
|
24
|
+
:key="name"
|
|
25
|
+
>
|
|
26
|
+
<slot :name="name" v-bind="slotProps" />
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<template v-if="slots.suffix" #suffix="suffixProps">
|
|
30
|
+
<slot name="suffix" v-bind="suffixProps" />
|
|
31
|
+
</template>
|
|
32
|
+
<template v-else-if="showClear" #suffix>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
aria-label="Clear"
|
|
36
|
+
data-slot="clear"
|
|
37
|
+
class="group-hover:grid group-focus:grid group-focus-within:grid hidden size-4 place-items-center rounded-sm text-ink-gray-5 hover:bg-surface-gray-3 hover:text-ink-gray-7 focus:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3"
|
|
38
|
+
@click="clearValue"
|
|
39
|
+
@pointerdown.stop
|
|
40
|
+
>
|
|
41
|
+
<span class="lucide-x size-3.5" />
|
|
42
|
+
</button>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<template v-if="slots['item-create']" #item-create="slotProps">
|
|
46
|
+
<slot name="item-create" v-bind="slotProps" />
|
|
47
|
+
</template>
|
|
48
|
+
<template v-else #item-create="{ query }">
|
|
49
|
+
<div class="flex">
|
|
50
|
+
<span class="truncate">
|
|
51
|
+
Create
|
|
52
|
+
<span v-if="query" class="font-medium text-ink-gray-8">
|
|
53
|
+
{{ query }}
|
|
54
|
+
</span>
|
|
55
|
+
</span>
|
|
56
|
+
</div>
|
|
17
57
|
</template>
|
|
18
58
|
</Combobox>
|
|
19
59
|
</div>
|
|
20
60
|
</template>
|
|
21
61
|
|
|
22
62
|
<script setup lang="ts">
|
|
23
|
-
import {
|
|
24
|
-
import { Combobox
|
|
25
|
-
import
|
|
63
|
+
import { computed, ref, useSlots, watch } from 'vue'
|
|
64
|
+
import { Combobox } from '../../src/components/Combobox'
|
|
65
|
+
import type {
|
|
66
|
+
ComboboxCustomOption,
|
|
67
|
+
ComboboxOption,
|
|
68
|
+
} from '../../src/components/Combobox/types'
|
|
26
69
|
import debounce from '../../src/utils/debounce'
|
|
27
|
-
// @ts-ignore - Vue SFC without explicit types
|
|
28
70
|
import { createResource } from '../../src/resources'
|
|
29
71
|
import { frappeRequest } from '../../src/utils/frappeRequest'
|
|
30
|
-
import type {
|
|
31
|
-
import LucidePlus from '~icons/lucide/plus'
|
|
72
|
+
import type { LinkEmits, LinkExposed, LinkOption, LinkProps } from './types'
|
|
32
73
|
|
|
33
74
|
const props = withDefaults(defineProps<LinkProps>(), {
|
|
34
|
-
label: '',
|
|
35
75
|
filters: () => ({}),
|
|
36
|
-
|
|
76
|
+
creatable: false,
|
|
77
|
+
disabled: false,
|
|
37
78
|
})
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
}>()
|
|
79
|
+
|
|
80
|
+
const model = defineModel<string | null>({ default: null })
|
|
81
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
82
|
+
const comboboxRef = ref<{ focus: () => void } | null>(null)
|
|
83
|
+
|
|
84
|
+
const emit = defineEmits<LinkEmits>()
|
|
85
|
+
|
|
42
86
|
defineOptions({ inheritAttrs: false })
|
|
43
87
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
88
|
+
const slots = useSlots()
|
|
89
|
+
|
|
90
|
+
const forwardedSlots = computed(() =>
|
|
91
|
+
Object.fromEntries(
|
|
92
|
+
Object.entries(slots).filter(
|
|
93
|
+
([name]) => name !== 'suffix' && name !== 'item-create',
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
50
97
|
|
|
51
98
|
const options = createResource({
|
|
52
99
|
url: 'frappe.desk.search.search_link',
|
|
@@ -57,31 +104,35 @@ const options = createResource({
|
|
|
57
104
|
},
|
|
58
105
|
method: 'POST',
|
|
59
106
|
resourceFetcher: frappeRequest,
|
|
60
|
-
transform: (data:
|
|
61
|
-
|
|
107
|
+
transform: (data: LinkOption[]): LinkOption[] =>
|
|
108
|
+
data.map((doc: any) => ({
|
|
62
109
|
label: doc.label || doc.value,
|
|
63
110
|
value: doc.value,
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
description: doc.description,
|
|
112
|
+
})),
|
|
66
113
|
})
|
|
67
114
|
|
|
68
|
-
const createNewOption = {
|
|
69
|
-
type: 'custom'
|
|
70
|
-
key: '
|
|
115
|
+
const createNewOption: ComboboxCustomOption = {
|
|
116
|
+
type: 'custom',
|
|
117
|
+
key: 'create',
|
|
71
118
|
label: 'Create New',
|
|
72
|
-
|
|
73
|
-
condition: () =>
|
|
119
|
+
slot: 'create',
|
|
120
|
+
condition: ({ query }: { query: string }) => Boolean(query.trim()),
|
|
74
121
|
onClick: ({ query }) => emit('create', query),
|
|
75
|
-
}
|
|
122
|
+
}
|
|
76
123
|
|
|
77
|
-
const linkOptions = computed(() => {
|
|
124
|
+
const linkOptions = computed<ComboboxOption[]>(() => {
|
|
78
125
|
const _options = options.data || []
|
|
79
|
-
if (props.
|
|
126
|
+
if (props.creatable) {
|
|
80
127
|
return [..._options, createNewOption]
|
|
81
128
|
}
|
|
82
129
|
return _options
|
|
83
130
|
})
|
|
84
131
|
|
|
132
|
+
const showClear = computed(
|
|
133
|
+
() => !props.disabled && !!model.value && !props.required,
|
|
134
|
+
)
|
|
135
|
+
|
|
85
136
|
const loadOptions = (txt: string = '') => {
|
|
86
137
|
options.update({
|
|
87
138
|
params: {
|
|
@@ -93,12 +144,20 @@ const loadOptions = (txt: string = '') => {
|
|
|
93
144
|
options.reload()
|
|
94
145
|
}
|
|
95
146
|
|
|
96
|
-
const handleInputChange = debounce((
|
|
97
|
-
loadOptions(
|
|
147
|
+
const handleInputChange = debounce((value: string) => {
|
|
148
|
+
loadOptions(value || '')
|
|
98
149
|
}, 300)
|
|
99
150
|
|
|
151
|
+
const clearValue = () => {
|
|
152
|
+
model.value = null
|
|
153
|
+
open.value = false
|
|
154
|
+
comboboxRef.value?.focus()
|
|
155
|
+
}
|
|
156
|
+
|
|
100
157
|
watch([() => props.doctype, () => props.filters], () => loadOptions(''), {
|
|
101
158
|
immediate: true,
|
|
102
159
|
deep: true,
|
|
103
160
|
})
|
|
161
|
+
|
|
162
|
+
defineExpose<LinkExposed>({ reload: () => loadOptions('') })
|
|
104
163
|
</script>
|
package/frappe/Link/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { default as Link } from './Link.vue'
|
|
2
|
-
export type { LinkProps } from './types'
|
|
2
|
+
export type { LinkEmits, LinkExposed, LinkOption, LinkProps } from './types'
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { Link } from 'frappe-ui/frappe'
|
|
4
|
+
import { useMockSearchLink, MOCK_LOST_REASONS } from './_mock'
|
|
5
|
+
|
|
6
|
+
// Local copy of the seed list so created rows don't leak into the sibling
|
|
7
|
+
// Labeling story on the same docs page. Both variations share this list —
|
|
8
|
+
// pushing through either Link makes the new option visible in the other.
|
|
9
|
+
const reasons = [...MOCK_LOST_REASONS]
|
|
10
|
+
useMockSearchLink({ 'CRM Lost Reason': reasons })
|
|
11
|
+
|
|
12
|
+
const reasonA = ref<string | null>(null)
|
|
13
|
+
const openA = ref(false)
|
|
14
|
+
const lastCreateA = ref<string | null>(null)
|
|
15
|
+
|
|
16
|
+
function handleCreateA(query: string) {
|
|
17
|
+
// Real apps open a create dialog and persist the record; on success they
|
|
18
|
+
// assign the new primary key back to v-model. Here we mimic that flow:
|
|
19
|
+
// append to the mock dataset, select it, and close the popover.
|
|
20
|
+
if (!reasons.some((r) => r.value === query)) reasons.push({ value: query })
|
|
21
|
+
reasonA.value = query
|
|
22
|
+
openA.value = false
|
|
23
|
+
lastCreateA.value = query
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const reasonB = ref<string | null>(null)
|
|
27
|
+
const openB = ref(false)
|
|
28
|
+
|
|
29
|
+
function handleCreateB(query: string) {
|
|
30
|
+
if (!reasons.some((r) => r.value === query)) reasons.push({ value: query })
|
|
31
|
+
reasonB.value = query
|
|
32
|
+
openB.value = false
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<div class="w-full !py-20 grid place-items-center">
|
|
38
|
+
<div class="grid w-96 gap-8">
|
|
39
|
+
<div class="grid gap-3">
|
|
40
|
+
<Link
|
|
41
|
+
v-model="reasonA"
|
|
42
|
+
v-model:open="openA"
|
|
43
|
+
doctype="CRM Lost Reason"
|
|
44
|
+
label="Lost reason"
|
|
45
|
+
placeholder="Select or create a reason"
|
|
46
|
+
creatable
|
|
47
|
+
@create="handleCreateA"
|
|
48
|
+
/>
|
|
49
|
+
|
|
50
|
+
<div class="text-sm text-ink-gray-5">
|
|
51
|
+
Selected: <code class="text-ink-gray-7">{{ reasonA || 'None' }}</code>
|
|
52
|
+
</div>
|
|
53
|
+
<div v-if="lastCreateA" class="text-sm text-ink-gray-5">
|
|
54
|
+
Last <code>@create</code> query:
|
|
55
|
+
<code class="text-ink-gray-7">{{ lastCreateA }}</code>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="grid gap-3">
|
|
60
|
+
<div class="text-base text-ink-gray-5">Customised create row</div>
|
|
61
|
+
<Link
|
|
62
|
+
v-model="reasonB"
|
|
63
|
+
v-model:open="openB"
|
|
64
|
+
doctype="CRM Lost Reason"
|
|
65
|
+
label="Lost reason"
|
|
66
|
+
placeholder="Type a new reason…"
|
|
67
|
+
creatable
|
|
68
|
+
@create="handleCreateB"
|
|
69
|
+
>
|
|
70
|
+
<template #item-create="{ query }">
|
|
71
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
72
|
+
<span
|
|
73
|
+
class="lucide-plus size-3.5 text-ink-gray-5 shrink-0"
|
|
74
|
+
/>
|
|
75
|
+
<span class="truncate">
|
|
76
|
+
Add new reason
|
|
77
|
+
<span
|
|
78
|
+
v-if="query"
|
|
79
|
+
class="font-medium text-ink-gray-8 italic"
|
|
80
|
+
>
|
|
81
|
+
“{{ query }}”
|
|
82
|
+
</span>
|
|
83
|
+
</span>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
</Link>
|
|
87
|
+
|
|
88
|
+
<p class="text-p-xs text-ink-gray-5">
|
|
89
|
+
Use <code>#item-create</code> to override the default create row —
|
|
90
|
+
add an icon, helper text, or any markup. Slot scope is
|
|
91
|
+
<code>{ query }</code>.
|
|
92
|
+
</p>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|