frappe-ui 1.0.0-beta.72 → 1.0.0-beta.73

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.72",
3
+ "version": "1.0.0-beta.73",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
@@ -114,7 +114,9 @@ defineSlots<DatePickerSlots>()
114
114
  // ── Popover open state ───────────────────────────────────────────────────────
115
115
 
116
116
  const shellRef = ref<PickerShellExposed | null>(null)
117
- const isOpen = ref(false)
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
- const isOpen = ref(false)
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
- const isOpen = ref(false)
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: 'Replaces the results with a loading state.',
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`. The `:loading` prop swaps the result body for a loading state. 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.
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
- /** Replaces the results with a loading state. */
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>
@@ -3,6 +3,7 @@ export type {
3
3
  TabButton,
4
4
  TabButtonIcon,
5
5
  TabButtonsEmits,
6
+ TabButtonsExposed,
6
7
  TabButtonsProps,
7
8
  TabButtonsSlots,
8
9
  TabButtonValue,
@@ -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
- const isOpen = ref(false)
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
- highlightIndex.value = -1
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 opens with a custom trigger (no `TextInput` to type
96
- * into, so focus should jump straight into the content).
97
- * The default trigger keeps focus on the `TextInput` for typing. */
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
- emit('open')
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,
@@ -129,8 +129,13 @@ if (import.meta.env.DEV) {
129
129
  )
130
130
  }
131
131
 
132
+ // Membership is asked once per rendered row and once per universe entry on
133
+ // every select-all recompute, so scanning the `selection` array each time costs
134
+ // rows x selection. The Set is rebuilt only when `selection` itself changes.
135
+ const selectionSet = computed(() => new Set(selection.value))
136
+
132
137
  function isSelected(value: string) {
133
- return selection.value.includes(value)
138
+ return selectionSet.value.has(value)
134
139
  }
135
140
 
136
141
  function toggleSelection(value: string) {
@@ -157,9 +162,11 @@ function setAllValues(values: string[]) {
157
162
  const selectAllState = computed<'none' | 'some' | 'all'>(() => {
158
163
  const universe = allValues.value
159
164
  if (!universe.length) return 'none'
160
- const selectedCount = universe.filter((value) =>
161
- selection.value.includes(value),
162
- ).length
165
+ const selected = selectionSet.value
166
+ let selectedCount = 0
167
+ for (const value of universe) {
168
+ if (selected.has(value)) selectedCount++
169
+ }
163
170
  if (selectedCount === 0) return 'none'
164
171
  return selectedCount === universe.length ? 'all' : 'some'
165
172
  })
@@ -90,13 +90,19 @@ const { rows, wrapperProps, anchor } = useVirtualRows(
90
90
  // every row's value — even the virtualized ones that aren't mounted. Uses the
91
91
  // same `getItemValue` as the render `:key` and scoped `value` slot prop, so
92
92
  // row identity has one source.
93
- watch(
94
- () => props.items,
95
- (items) => {
96
- context?.setAllValues(items.map((item, i) => getItemValue(item, i)))
97
- },
98
- { immediate: true },
93
+ //
94
+ // Deriving it through a computed keeps the two in step: the watcher then tracks
95
+ // what identity is actually made of — the array's entries, the active `rowKey`,
96
+ // and the item fields that key reads — so a push, a splice, a swapped entry, a
97
+ // renamed id or a different `rowKey` all move the universe. Watching
98
+ // `props.items` alone only sees the array swapped for another one, and an item
99
+ // field nothing reads for identity stays untracked: this is not a deep watch.
100
+ const itemValues = computed(() =>
101
+ props.items.map((item, i) => getItemValue(item, i)),
99
102
  )
103
+ watch(itemValues, (values) => context?.setAllValues(values), {
104
+ immediate: true,
105
+ })
100
106
  onBeforeUnmount(() => context?.setAllValues([]))
101
107
 
102
108
  function getItemValue(item: T, index: number) {