frappe-ui 1.0.0-beta.72 → 1.0.0-beta.74
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/ListView/ListFooter.vue +7 -3
- package/experimental.ts +0 -11
- package/package.json +65 -16
- package/src/components/DatePicker/DatePicker.vue +3 -1
- package/src/components/DatePicker/DateRangePicker.vue +3 -1
- package/src/components/DatePicker/DateTimePicker.vue +3 -1
- package/src/components/MultiSelect/MultiSelect.api.md +1 -1
- package/src/components/MultiSelect/MultiSelect.md +3 -1
- package/src/components/MultiSelect/types.ts +5 -1
- package/src/components/TabButtons/TabButtons.md +27 -0
- package/src/components/TabButtons/TabButtons.vue +26 -3
- package/src/components/TabButtons/index.ts +1 -0
- package/src/components/TabButtons/types.ts +10 -0
- package/src/components/Tabs/Tabs.vue +35 -0
- package/src/components/TimePicker/TimePicker.vue +18 -4
- package/src/components/shared/picker/PickerShell.vue +35 -14
- package/src/molecules/code-editor/CodeEditor.vue +82 -0
- package/src/molecules/code-editor/CodeEditorContent.vue +138 -0
- package/src/molecules/code-editor/code-editor-context.ts +46 -0
- package/src/molecules/code-editor/extensions.ts +133 -0
- package/src/molecules/code-editor/index.ts +32 -0
- package/src/molecules/code-editor/kit.ts +198 -0
- package/src/molecules/code-editor/languages.ts +99 -0
- package/src/molecules/code-editor/style.css +252 -0
- package/src/molecules/code-editor/types.ts +19 -0
- package/src/molecules/code-editor/useCodeEditor.ts +222 -0
- package/src/molecules/list/List.vue +11 -4
- package/src/molecules/list/ListRows.vue +12 -6
- package/tailwind/content.js +0 -1
- package/vite/README.md +43 -0
- package/vite/codeLanguages.js +206 -0
- package/vite/index.d.ts +10 -0
- package/vite/index.js +8 -0
- package/vitepress/plugins/componentTransformer.ts +6 -0
- package/vitepress/plugins/preventCircularChunks.ts +15 -0
- package/experimental/CodeEditor/CodeEditor.api.md +0 -140
- package/experimental/CodeEditor/CodeEditor.md +0 -106
- package/experimental/CodeEditor/CodeEditor.vue +0 -548
- package/experimental/CodeEditor/CodePreview.vue +0 -45
- package/experimental/CodeEditor/index.ts +0 -18
- package/experimental/CodeEditor/languages.ts +0 -63
- package/experimental/CodeEditor/theme.ts +0 -272
- package/experimental/CodeEditor/types.ts +0 -69
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
<slot name="left">
|
|
5
5
|
<TabButtons
|
|
6
6
|
v-model="pageLengthCount"
|
|
7
|
-
:
|
|
7
|
+
:options="
|
|
8
|
+
pageLengthOptions.map((o) => ({ label: String(o), value: o }))
|
|
9
|
+
"
|
|
8
10
|
/>
|
|
9
11
|
</slot>
|
|
10
12
|
<slot name="right">
|
|
@@ -27,7 +29,7 @@
|
|
|
27
29
|
</template>
|
|
28
30
|
<script setup>
|
|
29
31
|
import TabButtons from '#components/TabButtons/TabButtons.vue'
|
|
30
|
-
import {
|
|
32
|
+
import { computed } from 'vue'
|
|
31
33
|
|
|
32
34
|
const props = defineProps({
|
|
33
35
|
modelValue: {
|
|
@@ -51,7 +53,9 @@ const pageLengthCount = computed({
|
|
|
51
53
|
set: (value) => emit('update:modelValue', value),
|
|
52
54
|
})
|
|
53
55
|
|
|
54
|
-
const pageLengthOptions =
|
|
56
|
+
const pageLengthOptions = computed(
|
|
57
|
+
() => props.options.pageLengthOptions || [20, 50, 100],
|
|
58
|
+
)
|
|
55
59
|
|
|
56
60
|
const showLoadMore = computed(() => {
|
|
57
61
|
return (
|
package/experimental.ts
CHANGED
|
@@ -10,17 +10,6 @@ export {
|
|
|
10
10
|
InputError,
|
|
11
11
|
} from './src/components/InputLabeling'
|
|
12
12
|
export { useInputLabeling } from './src/composables/useInputLabeling'
|
|
13
|
-
export {
|
|
14
|
-
CodeEditor,
|
|
15
|
-
CodePreview,
|
|
16
|
-
loadLanguage,
|
|
17
|
-
} from './experimental/CodeEditor'
|
|
18
|
-
export type {
|
|
19
|
-
CodeLanguage,
|
|
20
|
-
CodeEditorProps,
|
|
21
|
-
CodeEditorEmits,
|
|
22
|
-
CodePreviewProps,
|
|
23
|
-
} from './experimental/CodeEditor'
|
|
24
13
|
// Calendar family. Moved out of root (#1020, redirect of #989) — parked
|
|
25
14
|
// here, unstable, with its public API unchanged, until a redesigned
|
|
26
15
|
// calendar family replaces it. Migration is the import-path change only.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frappe-ui",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.74",
|
|
4
4
|
"description": "A set of components and utilities for rapid UI development",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.19.0"
|
|
@@ -84,6 +84,10 @@
|
|
|
84
84
|
"import": "./src/molecules/editor/index.ts",
|
|
85
85
|
"types": "./src/molecules/editor/index.ts"
|
|
86
86
|
},
|
|
87
|
+
"./code-editor": {
|
|
88
|
+
"import": "./src/molecules/code-editor/index.ts",
|
|
89
|
+
"types": "./src/molecules/code-editor/index.ts"
|
|
90
|
+
},
|
|
87
91
|
"./list": {
|
|
88
92
|
"import": "./src/molecules/list/index.ts",
|
|
89
93
|
"types": "./src/molecules/list/index.ts"
|
|
@@ -158,25 +162,15 @@
|
|
|
158
162
|
"author": "Frappe Technologies Pvt. Ltd.",
|
|
159
163
|
"license": "MIT",
|
|
160
164
|
"dependencies": {
|
|
161
|
-
"@
|
|
162
|
-
"@codemirror/lang-css": "^6.3.0",
|
|
163
|
-
"@codemirror/lang-html": "^6.4.9",
|
|
164
|
-
"@codemirror/lang-javascript": "^6.2.2",
|
|
165
|
-
"@codemirror/lang-json": "^6.0.1",
|
|
166
|
-
"@codemirror/lang-markdown": "^6.3.1",
|
|
167
|
-
"@codemirror/lang-python": "^6.1.6",
|
|
168
|
-
"@codemirror/lang-sass": "^6.0.2",
|
|
169
|
-
"@codemirror/lang-sql": "^6.8.0",
|
|
170
|
-
"@codemirror/lang-xml": "^6.1.0",
|
|
171
|
-
"@codemirror/lang-yaml": "^6.1.3",
|
|
172
|
-
"@codemirror/language": "^6.0.0",
|
|
173
|
-
"@codemirror/lint": "^6.0.0",
|
|
165
|
+
"@codemirror/autocomplete": "^6.0.0",
|
|
174
166
|
"@codemirror/commands": "^6.0.0",
|
|
167
|
+
"@codemirror/language": "^6.0.0",
|
|
168
|
+
"@codemirror/search": "^6.0.0",
|
|
175
169
|
"@codemirror/state": "^6.0.0",
|
|
176
170
|
"@codemirror/view": "^6.0.0",
|
|
177
|
-
"@lezer/highlight": "^1.0.0",
|
|
178
171
|
"@floating-ui/dom": "^1.7.4",
|
|
179
172
|
"@floating-ui/vue": "^1.1.9",
|
|
173
|
+
"@lezer/highlight": "^1.0.0",
|
|
180
174
|
"@tailwindcss/forms": "^0.5.3",
|
|
181
175
|
"@tailwindcss/typography": "^0.5.16",
|
|
182
176
|
"@tiptap/core": "^3.26.0",
|
|
@@ -209,8 +203,8 @@
|
|
|
209
203
|
"@tiptap/starter-kit": "^3.26.0",
|
|
210
204
|
"@tiptap/suggestion": "^3.26.0",
|
|
211
205
|
"@tiptap/vue-3": "^3.26.0",
|
|
206
|
+
"@vue/compiler-sfc": "^3.5.13",
|
|
212
207
|
"@vueuse/core": "^14.1.0",
|
|
213
|
-
"codemirror": "^6.0.1",
|
|
214
208
|
"dayjs": "^1.11.13",
|
|
215
209
|
"dompurify": "^3.4.0",
|
|
216
210
|
"echarts": "^6.1.0",
|
|
@@ -232,6 +226,17 @@
|
|
|
232
226
|
"vue-sonner": "^2.0.9"
|
|
233
227
|
},
|
|
234
228
|
"peerDependencies": {
|
|
229
|
+
"@codemirror/lang-css": "^6.3.0",
|
|
230
|
+
"@codemirror/lang-html": "^6.4.9",
|
|
231
|
+
"@codemirror/lang-javascript": "^6.2.2",
|
|
232
|
+
"@codemirror/lang-json": "^6.0.1",
|
|
233
|
+
"@codemirror/lang-markdown": "^6.3.1",
|
|
234
|
+
"@codemirror/lang-python": "^6.1.6",
|
|
235
|
+
"@codemirror/lang-sass": "^6.0.2",
|
|
236
|
+
"@codemirror/lang-sql": "^6.8.0",
|
|
237
|
+
"@codemirror/lang-xml": "^6.1.0",
|
|
238
|
+
"@codemirror/lang-yaml": "^6.1.3",
|
|
239
|
+
"@codemirror/lint": "^6.0.0",
|
|
235
240
|
"@shikijs/transformers": ">=3.15.0",
|
|
236
241
|
"@vue/compiler-dom": ">=3.5.0",
|
|
237
242
|
"shiki": ">=3.15.0",
|
|
@@ -242,6 +247,39 @@
|
|
|
242
247
|
"vue-router": "^4.1.6"
|
|
243
248
|
},
|
|
244
249
|
"peerDependenciesMeta": {
|
|
250
|
+
"@codemirror/lang-css": {
|
|
251
|
+
"optional": true
|
|
252
|
+
},
|
|
253
|
+
"@codemirror/lang-html": {
|
|
254
|
+
"optional": true
|
|
255
|
+
},
|
|
256
|
+
"@codemirror/lang-javascript": {
|
|
257
|
+
"optional": true
|
|
258
|
+
},
|
|
259
|
+
"@codemirror/lang-json": {
|
|
260
|
+
"optional": true
|
|
261
|
+
},
|
|
262
|
+
"@codemirror/lang-markdown": {
|
|
263
|
+
"optional": true
|
|
264
|
+
},
|
|
265
|
+
"@codemirror/lang-python": {
|
|
266
|
+
"optional": true
|
|
267
|
+
},
|
|
268
|
+
"@codemirror/lang-sass": {
|
|
269
|
+
"optional": true
|
|
270
|
+
},
|
|
271
|
+
"@codemirror/lang-sql": {
|
|
272
|
+
"optional": true
|
|
273
|
+
},
|
|
274
|
+
"@codemirror/lang-xml": {
|
|
275
|
+
"optional": true
|
|
276
|
+
},
|
|
277
|
+
"@codemirror/lang-yaml": {
|
|
278
|
+
"optional": true
|
|
279
|
+
},
|
|
280
|
+
"@codemirror/lint": {
|
|
281
|
+
"optional": true
|
|
282
|
+
},
|
|
245
283
|
"@shikijs/transformers": {
|
|
246
284
|
"optional": true
|
|
247
285
|
},
|
|
@@ -259,6 +297,17 @@
|
|
|
259
297
|
}
|
|
260
298
|
},
|
|
261
299
|
"devDependencies": {
|
|
300
|
+
"@codemirror/lang-css": "^6.3.0",
|
|
301
|
+
"@codemirror/lang-html": "^6.4.9",
|
|
302
|
+
"@codemirror/lang-javascript": "^6.2.2",
|
|
303
|
+
"@codemirror/lang-json": "^6.0.1",
|
|
304
|
+
"@codemirror/lang-markdown": "^6.3.1",
|
|
305
|
+
"@codemirror/lang-python": "^6.1.6",
|
|
306
|
+
"@codemirror/lang-sass": "^6.0.2",
|
|
307
|
+
"@codemirror/lang-sql": "^6.8.0",
|
|
308
|
+
"@codemirror/lang-xml": "^6.1.0",
|
|
309
|
+
"@codemirror/lang-yaml": "^6.1.3",
|
|
310
|
+
"@codemirror/lint": "^6.0.0",
|
|
262
311
|
"@cypress/code-coverage": "^3.13.10",
|
|
263
312
|
"@shikijs/transformers": "^3.15.0",
|
|
264
313
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
@@ -114,7 +114,9 @@ defineSlots<DatePickerSlots>()
|
|
|
114
114
|
// ── Popover open state ───────────────────────────────────────────────────────
|
|
115
115
|
|
|
116
116
|
const shellRef = ref<PickerShellExposed | null>(null)
|
|
117
|
-
|
|
117
|
+
// Seeded from the prop, so a parent that mounts the picker with `open` already
|
|
118
|
+
// true gets an open panel. The watch below only sees later changes.
|
|
119
|
+
const isOpen = ref(props.open === true)
|
|
118
120
|
|
|
119
121
|
watch(
|
|
120
122
|
() => props.open,
|
|
@@ -122,7 +122,9 @@ defineSlots<DateRangePickerSlots>()
|
|
|
122
122
|
// ── Popover open state ───────────────────────────────────────────────────────
|
|
123
123
|
|
|
124
124
|
const shellRef = ref<PickerShellExposed | null>(null)
|
|
125
|
-
|
|
125
|
+
// Seeded from the prop, so a parent that mounts the picker with `open` already
|
|
126
|
+
// true gets an open panel. The watch below only sees later changes.
|
|
127
|
+
const isOpen = ref(props.open === true)
|
|
126
128
|
|
|
127
129
|
watch(
|
|
128
130
|
() => props.open,
|
|
@@ -134,7 +134,9 @@ defineSlots<DateTimePickerSlots>()
|
|
|
134
134
|
// ── Popover open state ───────────────────────────────────────────────────────
|
|
135
135
|
|
|
136
136
|
const shellRef = ref<PickerShellExposed | null>(null)
|
|
137
|
-
|
|
137
|
+
// Seeded from the prop, so a parent that mounts the picker with `open` already
|
|
138
|
+
// true gets an open panel. The watch below only sees later changes.
|
|
139
|
+
const isOpen = ref(props.open === true)
|
|
138
140
|
|
|
139
141
|
watch(
|
|
140
142
|
() => props.open,
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
72
|
name: 'loading',
|
|
73
|
-
description: '
|
|
73
|
+
description: 'Marks a fetch as in flight. With the search row showing, a spinner appears\nin it and the options you passed stay selectable; under `hide-search` a\nloading row replaces the results instead.',
|
|
74
74
|
required: false,
|
|
75
75
|
type: 'boolean',
|
|
76
76
|
default: 'false'
|
|
@@ -37,7 +37,9 @@ Use `#search-prefix` and `#search-suffix` to add content around the popover's se
|
|
|
37
37
|
<ComponentPreview name="MultiSelect-SearchSlots" />
|
|
38
38
|
|
|
39
39
|
## Server Search
|
|
40
|
-
Fetch options from a server as the user types. Bind `v-model:query`, debounce the request, and feed the results back into `:options`.
|
|
40
|
+
Fetch options from a server as the user types. Bind `v-model:query`, debounce the request, and feed the results back into `:options`. Four things to watch for: pass `:filterable="false"` so the client doesn't substring-filter what the server already matched, drop stale responses with a request id so a slower earlier query can't overwrite the latest results, merge currently-selected items into the options array so chips stay resolvable after the query narrows the list, and clear the query yourself when the popover opens — see the note below on who owns it.
|
|
41
|
+
|
|
42
|
+
The `:loading` prop shows a spinner in the search row and leaves the options you passed rendering and selectable, so the user can go on picking from the previous results while the next ones arrive. Under `hide-search` there is no search row to put the spinner in, so a loading row replaces the results instead. Either way the empty state is suppressed while loading, so an in-flight fetch never reads as "No results". The popover carries a bare `data-loading` attribute while the fetch is open, so you can style the wait yourself.
|
|
41
43
|
|
|
42
44
|
<ComponentPreview name="MultiSelect-AsyncOptions" />
|
|
43
45
|
|
|
@@ -90,7 +90,11 @@ export interface MultiSelectProps extends InputLabelingProps {
|
|
|
90
90
|
/** Hides the in-popover search input. */
|
|
91
91
|
hideSearch?: boolean
|
|
92
92
|
|
|
93
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Marks a fetch as in flight. With the search row showing, a spinner appears
|
|
95
|
+
* in it and the options you passed stay selectable; under `hide-search` a
|
|
96
|
+
* loading row replaces the results instead.
|
|
97
|
+
*/
|
|
94
98
|
loading?: boolean
|
|
95
99
|
|
|
96
100
|
/**
|
|
@@ -64,6 +64,33 @@ items.
|
|
|
64
64
|
|
|
65
65
|
<ComponentPreview name="TabButtons-PrefixSuffix" />
|
|
66
66
|
|
|
67
|
+
## Template ref
|
|
68
|
+
|
|
69
|
+
`focus()` is the one method every focusable control in the library exposes. It
|
|
70
|
+
moves focus to the selected option, or to the first enabled one when nothing is
|
|
71
|
+
selected — the same tab a `Tab` press reaches, since the group is one tabstop.
|
|
72
|
+
It takes the native `FocusOptions`, so `focus({ preventScroll: true })` moves
|
|
73
|
+
focus without scrolling the tab into view.
|
|
74
|
+
|
|
75
|
+
Disabled options are skipped. A group with no options, or with every option
|
|
76
|
+
disabled, has nothing to focus and the call does nothing.
|
|
77
|
+
|
|
78
|
+
```vue
|
|
79
|
+
<script setup lang="ts">
|
|
80
|
+
import { useTemplateRef } from 'vue'
|
|
81
|
+
|
|
82
|
+
const view = useTemplateRef('view')
|
|
83
|
+
|
|
84
|
+
function reset() {
|
|
85
|
+
view.value?.focus()
|
|
86
|
+
}
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<template>
|
|
90
|
+
<TabButtons ref="view" v-model="value" :options="options" />
|
|
91
|
+
</template>
|
|
92
|
+
```
|
|
93
|
+
|
|
67
94
|
## Styling a single tab
|
|
68
95
|
|
|
69
96
|
Each tab exposes data-attribute hooks for styling:
|
|
@@ -26,6 +26,7 @@ import type { BrowserTabBase } from '../shared/tabs/pillTypes'
|
|
|
26
26
|
import type {
|
|
27
27
|
TabButton,
|
|
28
28
|
TabButtonsEmits,
|
|
29
|
+
TabButtonsExposed,
|
|
29
30
|
TabButtonsProps,
|
|
30
31
|
TabButtonsSlots,
|
|
31
32
|
TabButtonValue,
|
|
@@ -147,15 +148,19 @@ const indicatorRect = ref<{
|
|
|
147
148
|
// slides in on mount.
|
|
148
149
|
const indicatorAnimated = ref(false)
|
|
149
150
|
|
|
151
|
+
// The rendered tab itself — the `<button>`, `<a href>` or `<RouterLink>` that
|
|
152
|
+
// takes focus and that the indicator measures. Not the track around it, and
|
|
153
|
+
// not the `Pill` inside it.
|
|
154
|
+
const TAB_BUTTON = '[data-slot="tab-button"]'
|
|
155
|
+
const ACTIVE_TAB_BUTTON = `${TAB_BUTTON}[data-state="active"]`
|
|
156
|
+
|
|
150
157
|
function measureIndicator() {
|
|
151
158
|
const track = trackRef.value
|
|
152
159
|
if (!track || !hasIndicator.value) {
|
|
153
160
|
indicatorRect.value = null
|
|
154
161
|
return
|
|
155
162
|
}
|
|
156
|
-
const checked = track.querySelector<HTMLElement>(
|
|
157
|
-
'[data-slot="tab-button"][data-state="active"]',
|
|
158
|
-
)
|
|
163
|
+
const checked = track.querySelector<HTMLElement>(ACTIVE_TAB_BUTTON)
|
|
159
164
|
if (!checked) {
|
|
160
165
|
indicatorRect.value = null
|
|
161
166
|
return
|
|
@@ -306,6 +311,24 @@ function tabElementProps(button: (typeof resolvedButtons.value)[number]) {
|
|
|
306
311
|
}
|
|
307
312
|
return { type: 'button' as const, disabled: button.disabled }
|
|
308
313
|
}
|
|
314
|
+
|
|
315
|
+
// INP-Q5: the group is one tabstop, so focus goes to the selected option, or
|
|
316
|
+
// to the first enabled one when nothing is selected — where a Tab press lands.
|
|
317
|
+
// A disabled option is skipped whichever form it renders as: `tabElement`
|
|
318
|
+
// gives a disabled `route`/`href` option a real disabled `<button>`, which
|
|
319
|
+
// cannot take focus at all. With nothing left to focus the call does nothing.
|
|
320
|
+
defineExpose<TabButtonsExposed>({
|
|
321
|
+
focus: (options?: FocusOptions) => {
|
|
322
|
+
const track = trackRef.value
|
|
323
|
+
if (!track) return
|
|
324
|
+
const target =
|
|
325
|
+
track.querySelector<HTMLElement>(
|
|
326
|
+
`${ACTIVE_TAB_BUTTON}:not([data-disabled])`,
|
|
327
|
+
) ??
|
|
328
|
+
track.querySelector<HTMLElement>(`${TAB_BUTTON}:not([data-disabled])`)
|
|
329
|
+
target?.focus(options)
|
|
330
|
+
},
|
|
331
|
+
})
|
|
309
332
|
</script>
|
|
310
333
|
|
|
311
334
|
<template>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { InputExposed } from '../../composables/inputTypes'
|
|
1
2
|
import type { RouteDestination } from '../shared/route'
|
|
2
3
|
import type {
|
|
3
4
|
TabIcon,
|
|
@@ -48,6 +49,15 @@ export interface TabButtonsEmits {
|
|
|
48
49
|
'update:modelValue': [value: TabButtonValue]
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* What a `<TabButtons>` template ref hands back. `focus()` moves focus to the
|
|
54
|
+
* selected option, or to the first enabled one when nothing is selected —
|
|
55
|
+
* the same element a `Tab` press reaches, since the group is one tabstop.
|
|
56
|
+
* A group with no options, or with every option disabled, has nothing to
|
|
57
|
+
* focus and the call does nothing.
|
|
58
|
+
*/
|
|
59
|
+
export interface TabButtonsExposed extends InputExposed {}
|
|
60
|
+
|
|
51
61
|
export interface TabButtonsSlots {
|
|
52
62
|
/** Slot before the tab button label. */
|
|
53
63
|
prefix?: (props: {
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import {
|
|
3
3
|
computed,
|
|
4
4
|
getCurrentInstance,
|
|
5
|
+
inject,
|
|
6
|
+
onUnmounted,
|
|
5
7
|
provide,
|
|
6
8
|
ref,
|
|
7
9
|
shallowRef,
|
|
8
10
|
watch,
|
|
9
11
|
} from 'vue'
|
|
10
12
|
import { TabsRoot } from 'reka-ui'
|
|
13
|
+
import { routerKey } from 'vue-router'
|
|
11
14
|
import TabList from './TabList.vue'
|
|
12
15
|
import TabTrigger from './TabTrigger.vue'
|
|
13
16
|
import TabPanel from './TabPanel.vue'
|
|
@@ -212,6 +215,38 @@ function triggerFor(value: TabValue) {
|
|
|
212
215
|
return triggers.value.find((t) => t.value() === value)
|
|
213
216
|
}
|
|
214
217
|
|
|
218
|
+
// The override lasts until the route leaves the page the user clicked from.
|
|
219
|
+
// Two things end it.
|
|
220
|
+
//
|
|
221
|
+
// A navigation that lands on a different path. That covers `/inbox` →
|
|
222
|
+
// `/sent`, and `/inbox` → `/inbox/42` too, where the same tab stays matched
|
|
223
|
+
// but the URL is a page the clicked tab does not stand for.
|
|
224
|
+
//
|
|
225
|
+
// A navigation that did not land — aborted by a guard, cancelled by a newer
|
|
226
|
+
// one, or a duplicate of the current URL — leaves `failure` set and changed
|
|
227
|
+
// nothing the user can see, so it must not throw away their click.
|
|
228
|
+
//
|
|
229
|
+
// `inject` rather than `useRouter`: Tabs is used with no router at all far
|
|
230
|
+
// more often than with one, and `useRouter` warns when the injection is
|
|
231
|
+
// missing.
|
|
232
|
+
const router = inject(routerKey, null)
|
|
233
|
+
if (router) {
|
|
234
|
+
const stopRouteReset = router.afterEach((to, from, failure) => {
|
|
235
|
+
if (failure) return
|
|
236
|
+
if (to.path !== from.path) routeOverride.value = null
|
|
237
|
+
})
|
|
238
|
+
onUnmounted(stopRouteReset)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// And a change in which tab the URL matches, whatever moved it. That includes
|
|
242
|
+
// a change with no navigation behind it, which the hook above cannot see — a
|
|
243
|
+
// trigger's `route` prop changing, or a routed trigger mounting that matches
|
|
244
|
+
// the current URL. Both have always ended the click.
|
|
245
|
+
//
|
|
246
|
+
// What neither rule catches keeps the click: a query-only or hash-only write
|
|
247
|
+
// that leaves the same tab matched. A panel that keeps its own state in the
|
|
248
|
+
// URL — a page number, a filter — writes exactly that, and clearing there
|
|
249
|
+
// would throw the user out of the panel they are standing in.
|
|
215
250
|
watch(routeSelected, () => {
|
|
216
251
|
routeOverride.value = null
|
|
217
252
|
})
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
</template>
|
|
98
98
|
|
|
99
99
|
<script setup lang="ts">
|
|
100
|
-
import { computed, nextTick, ref, watch } from 'vue'
|
|
100
|
+
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
101
101
|
import {
|
|
102
102
|
PopoverAnchor,
|
|
103
103
|
PopoverContent,
|
|
@@ -195,7 +195,9 @@ function onInteractOutside(event: Event) {
|
|
|
195
195
|
}
|
|
196
196
|
const uid = Math.random().toString(36).slice(2, 9)
|
|
197
197
|
|
|
198
|
-
|
|
198
|
+
// Seeded from the prop, so a parent that mounts the picker with `open` already
|
|
199
|
+
// true gets an open panel. The watch below only sees later changes.
|
|
200
|
+
const isOpen = ref(props.open === true)
|
|
199
201
|
|
|
200
202
|
// Canonical 24-hour value (`HH:mm` or `HH:mm:ss`) — the source of truth.
|
|
201
203
|
const canonicalValue = ref<string>(
|
|
@@ -522,16 +524,28 @@ function scrollOnOpen() {
|
|
|
522
524
|
})
|
|
523
525
|
}
|
|
524
526
|
|
|
527
|
+
function onOpened() {
|
|
528
|
+
highlightIndex.value = -1
|
|
529
|
+
scrollOnOpen()
|
|
530
|
+
}
|
|
531
|
+
|
|
525
532
|
watch(isOpen, (open) => {
|
|
526
533
|
emit('update:open', open)
|
|
527
534
|
if (open) {
|
|
528
|
-
|
|
529
|
-
scrollOnOpen()
|
|
535
|
+
onOpened()
|
|
530
536
|
} else {
|
|
531
537
|
isTyping.value = false
|
|
532
538
|
}
|
|
533
539
|
})
|
|
534
540
|
|
|
541
|
+
// A picker mounted with `open` already true never crosses the watch above, so
|
|
542
|
+
// the open-time work — the highlight seed and the scroll to the current value —
|
|
543
|
+
// runs here instead. No `update:open`: the parent is the one that asked for an
|
|
544
|
+
// open panel.
|
|
545
|
+
onMounted(() => {
|
|
546
|
+
if (isOpen.value) onOpened()
|
|
547
|
+
})
|
|
548
|
+
|
|
535
549
|
watch(
|
|
536
550
|
() => props.open,
|
|
537
551
|
(val) => {
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
</template>
|
|
62
62
|
|
|
63
63
|
<script setup lang="ts">
|
|
64
|
-
import { computed, nextTick, ref, watch } from 'vue'
|
|
64
|
+
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
65
65
|
import Popover from '../../Popover/Popover.vue'
|
|
66
66
|
import { TextInput } from '../../TextInput'
|
|
67
67
|
import { useReactiveSlots } from '../../../composables/useReactiveSlots'
|
|
@@ -92,9 +92,12 @@ const emit = defineEmits<{
|
|
|
92
92
|
/** Signal that the parent should move keyboard focus into the popover
|
|
93
93
|
* content (e.g. into a calendar grid). Fired:
|
|
94
94
|
* - When the user presses ↓ on the trigger.
|
|
95
|
-
* - When the popover
|
|
96
|
-
* into, so focus should jump straight into the
|
|
97
|
-
*
|
|
95
|
+
* - When a gesture opens the popover and the trigger is a custom one (no
|
|
96
|
+
* `TextInput` to type into, so focus should jump straight into the
|
|
97
|
+
* content).
|
|
98
|
+
* The default trigger keeps focus on the `TextInput` for typing. A shell
|
|
99
|
+
* that is already open on its first render never fires this: nobody asked
|
|
100
|
+
* for focus to move. */
|
|
98
101
|
(e: 'requestFocus'): void
|
|
99
102
|
}>()
|
|
100
103
|
|
|
@@ -174,19 +177,27 @@ const triggerSlotProps = computed<PickerShellTriggerSlotProps>(() => ({
|
|
|
174
177
|
|
|
175
178
|
const hasCustomTrigger = computed(() => !!slots.trigger)
|
|
176
179
|
|
|
180
|
+
// `moveFocus` is false on the mount path. Every other route into `onOpened`
|
|
181
|
+
// follows a gesture, and a panel the user just opened should take focus; a
|
|
182
|
+
// panel that is simply part of the first render follows no gesture, so moving
|
|
183
|
+
// focus would take it from wherever the page put it.
|
|
184
|
+
function onOpened(moveFocus = true) {
|
|
185
|
+
emit('open')
|
|
186
|
+
nextTick(() => {
|
|
187
|
+
panelId.value = popoverRef.value?.contentEl?.id || undefined
|
|
188
|
+
})
|
|
189
|
+
// Custom triggers (e.g. a button) have no typing context — once the
|
|
190
|
+
// popover is open the user wants to interact with the content. Signal
|
|
191
|
+
// the parent to move focus there. The default `TextInput` trigger
|
|
192
|
+
// keeps its focus so the user can type, and only the explicit ↓
|
|
193
|
+
// handler emits `requestFocus`.
|
|
194
|
+
if (moveFocus && hasCustomTrigger.value) emit('requestFocus')
|
|
195
|
+
}
|
|
196
|
+
|
|
177
197
|
watch(open, (val, prev) => {
|
|
178
198
|
if (val === prev) return
|
|
179
199
|
if (val) {
|
|
180
|
-
|
|
181
|
-
nextTick(() => {
|
|
182
|
-
panelId.value = popoverRef.value?.contentEl?.id || undefined
|
|
183
|
-
})
|
|
184
|
-
// Custom triggers (e.g. a button) have no typing context — once the
|
|
185
|
-
// popover is open the user wants to interact with the content. Signal
|
|
186
|
-
// the parent to move focus there. The default `TextInput` trigger
|
|
187
|
-
// keeps its focus so the user can type, and only the explicit ↓
|
|
188
|
-
// handler emits `requestFocus`.
|
|
189
|
-
if (hasCustomTrigger.value) emit('requestFocus')
|
|
200
|
+
onOpened()
|
|
190
201
|
} else {
|
|
191
202
|
// Restore focus to the trigger input if the popover content had focus
|
|
192
203
|
// (Esc, date selection in auto-close mode). Click-outside leaves focus
|
|
@@ -202,6 +213,16 @@ watch(open, (val, prev) => {
|
|
|
202
213
|
}
|
|
203
214
|
})
|
|
204
215
|
|
|
216
|
+
// A shell mounted with `open` already true never crosses the watch above, so
|
|
217
|
+
// the open-time work — the panel id behind `aria-controls`, the parent's draft
|
|
218
|
+
// initialization — runs here instead. No `update:open`: the parent is the one
|
|
219
|
+
// that asked for an open panel. Focus stays put: the default trigger does not
|
|
220
|
+
// take focus at mount either, because `:auto-focus="false"` cancels reka's
|
|
221
|
+
// mount autofocus, and a page has no way to know the panel was coming.
|
|
222
|
+
onMounted(() => {
|
|
223
|
+
if (open.value) onOpened(false)
|
|
224
|
+
})
|
|
225
|
+
|
|
205
226
|
defineExpose<PickerShellExposed>({
|
|
206
227
|
open: () => setOpen(true),
|
|
207
228
|
close,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Extension } from '@codemirror/state'
|
|
3
|
+
import type { EditorView } from '@codemirror/view'
|
|
4
|
+
import { useCodeEditor } from './useCodeEditor'
|
|
5
|
+
import { provideCodeEditor } from './code-editor-context'
|
|
6
|
+
import type { CodeEditorExposed } from './types'
|
|
7
|
+
|
|
8
|
+
const model = defineModel<string>({ default: '' })
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
/**
|
|
13
|
+
* Every capability the editor has: languages, keymaps, completion sources,
|
|
14
|
+
* themes. Required, and reactive — a new array is re-applied without
|
|
15
|
+
* recreating the view, so the document, the selection and the undo history
|
|
16
|
+
* survive a language change.
|
|
17
|
+
*/
|
|
18
|
+
extensions: Extension[]
|
|
19
|
+
editable?: boolean
|
|
20
|
+
autofocus?: boolean
|
|
21
|
+
}>(),
|
|
22
|
+
{
|
|
23
|
+
editable: true,
|
|
24
|
+
autofocus: false,
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const emit = defineEmits<{
|
|
29
|
+
/** Blur: the commit point. */
|
|
30
|
+
change: [value: string]
|
|
31
|
+
focus: [event: FocusEvent]
|
|
32
|
+
blur: [event: FocusEvent]
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
defineSlots<{
|
|
36
|
+
// The consumer owns the layout: render CodeEditorContent, and whatever chrome
|
|
37
|
+
// the app draws around it.
|
|
38
|
+
default?(props: { editor: EditorView | null }): any
|
|
39
|
+
}>()
|
|
40
|
+
|
|
41
|
+
const editor = useCodeEditor({
|
|
42
|
+
content: model,
|
|
43
|
+
extensions: () => props.extensions,
|
|
44
|
+
editable: () => props.editable,
|
|
45
|
+
autofocus: props.autofocus,
|
|
46
|
+
onChange(view) {
|
|
47
|
+
// Read straight off the view rather than off `model`: `change` is a
|
|
48
|
+
// side-event and must not depend on v-model timing.
|
|
49
|
+
emit('change', view.state.doc.toString())
|
|
50
|
+
},
|
|
51
|
+
onFocus(_view, event) {
|
|
52
|
+
emit('focus', event)
|
|
53
|
+
},
|
|
54
|
+
onBlur(_view, event) {
|
|
55
|
+
emit('blur', event)
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// Publish the view to the parts in the slot, so they need no explicit `:editor`.
|
|
60
|
+
provideCodeEditor(editor)
|
|
61
|
+
|
|
62
|
+
// Sanctioned template-ref escape hatch (ADR-0012): a parent's script reaches the
|
|
63
|
+
// live view — to focus it, insert at the cursor, or open the search panel — with
|
|
64
|
+
// no other surface that gets there. The exposed member is the view itself, so no
|
|
65
|
+
// new verbs join the imperative surface.
|
|
66
|
+
//
|
|
67
|
+
// A getter, and typed: Vue unwraps a handed-back ref at the proxy boundary, so
|
|
68
|
+
// `defineExpose({ editor })` would document a ref the caller never sees and
|
|
69
|
+
// leave that ref writable from outside. Same shape as `TextInput.vue`.
|
|
70
|
+
defineExpose<CodeEditorExposed>({
|
|
71
|
+
get editor() {
|
|
72
|
+
return editor.value
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<!-- Renderless. <CodeEditor> owns the view lifecycle and the content v-model,
|
|
79
|
+
and draws nothing: there is no label, no box and no toolbar here. The box
|
|
80
|
+
is <CodeEditorContent>, and the label belongs to the app's own field. -->
|
|
81
|
+
<slot :editor="editor" />
|
|
82
|
+
</template>
|