frappe-ui 1.0.0-beta.12 → 1.0.0-beta.13

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.12",
3
+ "version": "1.0.0-beta.13",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -12,11 +12,7 @@
12
12
  @click="emit('prev')"
13
13
  />
14
14
  <span class="text-sm-medium text-ink-gray-7">
15
- <span v-if="view === 'date'">
16
- {{ months[currentMonth] }} {{ currentYear }}
17
- </span>
18
- <span v-else-if="view === 'month'">{{ currentYear }}</span>
19
- <span v-else>{{ yearRangeStart }} - {{ yearRangeStart + 11 }}</span>
15
+ {{ months[currentMonth] }} {{ currentYear }}
20
16
  </span>
21
17
  <Button
22
18
  :class="{ invisible: hideNext }"
@@ -34,13 +30,9 @@
34
30
  label="cycle-calendar-view"
35
31
  @click="emit('cycleView')"
36
32
  >
37
- <span v-if="view === 'date'">
38
- {{ months[currentMonth] }} {{ currentYear }}
39
- </span>
40
- <span v-else-if="view === 'month'">{{ currentYear }}</span>
41
- <span v-else>{{ yearRangeStart }} - {{ yearRangeStart + 11 }}</span>
33
+ {{ months[currentMonth] }} {{ currentYear }}
42
34
  </Button>
43
- <div class="flex items-center">
35
+ <div v-if="view === 'date'" class="flex items-center">
44
36
  <Button
45
37
  v-if="!hidePrev"
46
38
  label="previous"
@@ -99,7 +91,7 @@
99
91
  <button
100
92
  v-else
101
93
  type="button"
102
- class="flex size-7 items-center justify-center text-sm transition-colors duration-100 focus:outline-none focus:ring-2 focus:ring-outline-gray-2"
94
+ class="flex size-7 items-center justify-center text-sm transition-colors duration-100"
103
95
  :class="cellClass(cell)"
104
96
  role="gridcell"
105
97
  :aria-selected="ariaSelected(cell)"
@@ -112,9 +104,13 @@
112
104
  :tabindex="isFocusedCell(cell) ? 0 : -1"
113
105
  @mouseenter="emit('hoverCell', cell.date)"
114
106
  @click="!cell.isUnavailable && emit('selectDate', cell.date)"
115
- @keydown.left.prevent="shiftFocus(cell.date.subtract(1, 'day'), -1)"
107
+ @keydown.left.prevent="
108
+ shiftFocus(cell.date.subtract(1, 'day'), -1)
109
+ "
116
110
  @keydown.right.prevent="shiftFocus(cell.date.add(1, 'day'), 1)"
117
- @keydown.up.prevent="shiftFocus(cell.date.subtract(7, 'day'), -1)"
111
+ @keydown.up.prevent="
112
+ shiftFocus(cell.date.subtract(7, 'day'), -1)
113
+ "
118
114
  @keydown.down.prevent="shiftFocus(cell.date.add(7, 'day'), 1)"
119
115
  @keydown.home.prevent="
120
116
  shiftFocus(cell.date.subtract(cell.date.day(), 'day'), -1)
@@ -151,54 +147,60 @@
151
147
  </div>
152
148
  </div>
153
149
  </div>
154
- <div
155
- v-else-if="view === 'month'"
156
- class="grid grid-cols-3 gap-1"
157
- role="grid"
158
- aria-label="Select month"
159
- >
160
- <button
161
- v-for="(m, i) in months"
162
- type="button"
163
- :key="m"
164
- class="py-2 text-sm rounded cursor-pointer text-center hover:bg-surface-gray-2 focus:outline-none focus:ring-2 focus:ring-brand-6"
165
- :class="{
166
- 'bg-surface-gray-9 text-ink-base hover:bg-surface-gray-9':
167
- i === currentMonth,
168
- }"
169
- :aria-selected="i === currentMonth ? 'true' : 'false'"
170
- @click="emit('selectMonth', i)"
150
+ <div v-else class="flex h-52 w-52" aria-label="Select month and year">
151
+ <div
152
+ ref="yearListRef"
153
+ class="relative flex w-1/2 flex-col gap-0.5 overflow-y-auto"
154
+ role="listbox"
155
+ aria-label="Select year"
171
156
  >
172
- {{ m.slice(0, 3) }}
173
- </button>
174
- </div>
175
- <div
176
- v-else
177
- class="grid grid-cols-3 gap-1"
178
- role="grid"
179
- aria-label="Select year"
180
- >
181
- <button
182
- v-for="y in yearRange"
183
- type="button"
184
- :key="y"
185
- class="py-2 text-sm rounded cursor-pointer text-center hover:bg-surface-gray-2 focus:outline-none focus:ring-2 focus:ring-brand-6"
186
- :class="{
187
- 'bg-surface-gray-9 text-ink-base hover:bg-surface-gray-9':
188
- y === currentYear,
189
- }"
190
- :aria-selected="y === currentYear ? 'true' : 'false'"
191
- @click="emit('selectYear', y)"
157
+ <button
158
+ v-for="y in years"
159
+ type="button"
160
+ :key="y"
161
+ class="w-full text-ink-gray-8 h-7 shrink-0 rounded py-1 text-sm text-center cursor-pointer transition-colors duration-100"
162
+ :class="
163
+ y === currentYear
164
+ ? 'bg-surface-gray-2 hover:bg-surface-gray-3'
165
+ : ' hover:bg-surface-gray-1'
166
+ "
167
+ :data-selected="y === currentYear ? '' : undefined"
168
+ :aria-selected="y === currentYear ? 'true' : 'false'"
169
+ @click="emit('selectYear', y)"
170
+ >
171
+ {{ y }}
172
+ </button>
173
+ </div>
174
+ <div
175
+ ref="monthListRef"
176
+ class="relative flex w-1/2 flex-col gap-0.5 overflow-y-auto pl-1.5"
177
+ role="listbox"
178
+ aria-label="Select month"
192
179
  >
193
- {{ y }}
194
- </button>
180
+ <button
181
+ v-for="(m, i) in months"
182
+ type="button"
183
+ :key="m"
184
+ class="w-full text-ink-gray-8 shrink-0 h-7 rounded py-1 text-sm text-center cursor-pointer transition-colors duration-100"
185
+ :class="
186
+ i === currentMonth
187
+ ? 'bg-surface-gray-2 hover:bg-surface-gray-3'
188
+ : ' hover:bg-surface-gray-1'
189
+ "
190
+ :data-selected="i === currentMonth ? '' : undefined"
191
+ :aria-selected="i === currentMonth ? 'true' : 'false'"
192
+ @click="emit('selectMonth', i)"
193
+ >
194
+ {{ m }}
195
+ </button>
196
+ </div>
195
197
  </div>
196
198
  </div>
197
199
  </div>
198
200
  </template>
199
201
 
200
202
  <script setup lang="ts">
201
- import { nextTick, ref, watch } from 'vue'
203
+ import { computed, nextTick, ref, watch } from 'vue'
202
204
  import type { Dayjs } from 'dayjs/esm'
203
205
  import { Button } from '../Button'
204
206
  import { months } from './utils'
@@ -220,8 +222,6 @@ interface Props {
220
222
  view: ViewMode
221
223
  currentYear: number
222
224
  currentMonth: number
223
- yearRangeStart: number
224
- yearRange: number[]
225
225
  weeks: CalendarPanelCell[][]
226
226
  /** Label for the optional Today/Now action button between prev/next. Empty/undefined hides it. */
227
227
  todayLabel?: string
@@ -281,6 +281,44 @@ const emit = defineEmits<{
281
281
  (e: 'update:focusedDate', d: Dayjs): void
282
282
  }>()
283
283
 
284
+ // ── Month-year split view ────────────────────────────────────────────────────
285
+ // The year column is a scrollable list. We bound it to [min, max] when provided
286
+ // (so unreachable years aren't offered) and otherwise fall back to a wide window
287
+ // around the current year. The current year is always included.
288
+ const years = computed<number[]>(() => {
289
+ const current = props.currentYear
290
+ const minY = props.min ? Number(props.min.slice(0, 4)) : current - 100
291
+ const maxY = props.max ? Number(props.max.slice(0, 4)) : current + 10
292
+ const start = Math.min(minY, current)
293
+ const end = Math.max(maxY, current)
294
+ return Array.from({ length: end - start + 1 }, (_, i) => start + i)
295
+ })
296
+
297
+ const yearListRef = ref<HTMLElement | null>(null)
298
+ const monthListRef = ref<HTMLElement | null>(null)
299
+
300
+ // Center the active year/month when the split view opens. Manual scrollTop (vs
301
+ // scrollIntoView) keeps the scroll contained to each list and never nudges the
302
+ // popover or page. Relies on the list containers being `relative` so the
303
+ // buttons' `offsetTop` is measured against them.
304
+ function centerSelected(container: HTMLElement | null) {
305
+ const el = container?.querySelector<HTMLElement>('[data-selected]')
306
+ if (!container || !el) return
307
+ container.scrollTop =
308
+ el.offsetTop - container.clientHeight / 2 + el.clientHeight / 2
309
+ }
310
+
311
+ watch(
312
+ () => props.view,
313
+ (view) => {
314
+ if (view !== 'monthYear') return
315
+ nextTick(() => {
316
+ centerSelected(yearListRef.value)
317
+ centerSelected(monthListRef.value)
318
+ })
319
+ },
320
+ )
321
+
284
322
  // ── Roving tabindex (Reka Calendar pattern) ──────────────────────────────────
285
323
  // The focused date is *controlled* — the parent owns the value and feeds it
286
324
  // in via `props.focusedDate`. Arrow keys compute a target, emit
@@ -295,9 +333,7 @@ function isFocusedCell(cell: CalendarPanelCell): boolean {
295
333
  return !!props.focusedDate && cell.date.isSame(props.focusedDate, 'day')
296
334
  }
297
335
 
298
- function pickInitialFocusDate(
299
- weeks: CalendarPanelCell[][],
300
- ): Dayjs | null {
336
+ function pickInitialFocusDate(weeks: CalendarPanelCell[][]): Dayjs | null {
301
337
  const flat = weeks.flat()
302
338
  const selected = flat.find(
303
339
  (c) =>
@@ -343,12 +379,7 @@ watch(
343
379
  // for an unbounded run of dates would otherwise spin forever.
344
380
  const MAX_SKIP_DISABLED_STEPS = 366
345
381
 
346
- function shiftFocus(
347
- target: Dayjs,
348
- dir: 1 | -1,
349
- retried = false,
350
- steps = 0,
351
- ) {
382
+ function shiftFocus(target: Dayjs, dir: 1 | -1, retried = false, steps = 0) {
352
383
  // Bounds check first — if the target is outside [min, max], the arrow
353
384
  // press is a no-op rather than trying to navigate into nothing. Matches
354
385
  // Reka's CalendarCellTrigger behavior.
@@ -417,37 +448,48 @@ function ariaLabel(cell: CalendarPanelCell): string {
417
448
  }
418
449
 
419
450
  function cellClass(cell: CalendarPanelCell): Array<string | false> {
420
- const inMonthCls = cell.inMonth ? 'text-ink-gray-8' : 'text-ink-gray-3'
421
- const todayCls = cell.isToday ? 'font-semibold text-ink-gray-9' : ''
451
+ // "Today" stays bold whether or not it's selected; its gray text color is kept
452
+ // separate so it can be dropped on selected cells.
453
+ const todayFont = cell.isToday ? 'font-semibold' : ''
454
+ // Resting text color for non-selected cells. Omitted on selected/range cells:
455
+ // both are `text-*` utilities, so leaving them in would win the CSS source-order
456
+ // tie against `text-ink-base` and the selected date would render gray.
457
+ const restingText = [
458
+ cell.inMonth ? 'text-ink-gray-8' : 'text-ink-gray-3',
459
+ cell.isToday ? 'text-ink-gray-9' : '',
460
+ ]
422
461
 
423
462
  if (cell.isUnavailable) {
424
- return [inMonthCls, todayCls, 'rounded opacity-30 cursor-not-allowed']
425
- }
426
-
427
- if (cell.isRangeStart || cell.isRangeEnd) {
428
463
  return [
429
- inMonthCls,
430
- todayCls,
431
- 'rounded bg-surface-gray-9 text-ink-base hover:bg-surface-gray-9 cursor-pointer',
464
+ 'rounded',
465
+ ...restingText,
466
+ todayFont,
467
+ 'opacity-30 cursor-not-allowed',
432
468
  ]
433
469
  }
434
470
 
435
- if (cell.inRange) {
471
+ if (cell.isRangeStart || cell.isRangeEnd || cell.isSelected) {
436
472
  return [
437
- inMonthCls,
438
- todayCls,
439
- 'rounded bg-surface-gray-3 hover:bg-surface-gray-3 cursor-pointer',
473
+ 'rounded',
474
+ todayFont,
475
+ 'bg-surface-gray-9 text-ink-base hover:bg-surface-gray-9 cursor-pointer',
440
476
  ]
441
477
  }
442
478
 
443
- if (cell.isSelected) {
479
+ if (cell.inRange) {
444
480
  return [
445
- inMonthCls,
446
- todayCls,
447
- 'rounded bg-surface-gray-9 text-ink-base hover:bg-surface-gray-9 cursor-pointer',
481
+ 'rounded',
482
+ ...restingText,
483
+ todayFont,
484
+ 'bg-surface-gray-3 hover:bg-surface-gray-3 cursor-pointer',
448
485
  ]
449
486
  }
450
487
 
451
- return [inMonthCls, todayCls, 'rounded hover:bg-surface-gray-2 cursor-pointer']
488
+ return [
489
+ 'rounded',
490
+ ...restingText,
491
+ todayFont,
492
+ 'hover:bg-surface-gray-2 cursor-pointer',
493
+ ]
452
494
  }
453
495
  </script>
@@ -59,8 +59,6 @@
59
59
  :view="view"
60
60
  :current-year="currentYear"
61
61
  :current-month="currentMonth"
62
- :year-range-start="yearRangeStart"
63
- :year-range="yearRange"
64
62
  :weeks="weeks"
65
63
  today-label="Today"
66
64
  :min="props.min"
@@ -223,8 +221,6 @@ const {
223
221
  view,
224
222
  currentYear,
225
223
  currentMonth,
226
- yearRangeStart,
227
- yearRange,
228
224
  prev,
229
225
  next,
230
226
  cycleView,
@@ -65,8 +65,6 @@
65
65
  :view="view"
66
66
  :current-year="currentYear"
67
67
  :current-month="currentMonth"
68
- :year-range-start="yearRangeStart"
69
- :year-range="yearRange"
70
68
  :weeks="weeks"
71
69
  :today-label="isDualPaneActive ? '' : 'Today'"
72
70
  :hide-next="isDualPaneActive"
@@ -91,8 +89,6 @@
91
89
  :view="view"
92
90
  :current-year="rightYear"
93
91
  :current-month="rightMonth"
94
- :year-range-start="yearRangeStart"
95
- :year-range="yearRange"
96
92
  :weeks="rightWeeks"
97
93
  hide-prev
98
94
  hide-today
@@ -286,8 +282,6 @@ const {
286
282
  view,
287
283
  currentYear,
288
284
  currentMonth,
289
- yearRangeStart,
290
- yearRange,
291
285
  prev,
292
286
  next,
293
287
  cycleView,
@@ -184,10 +184,12 @@ describe('DateTimePicker', () => {
184
184
  })
185
185
  })
186
186
 
187
- it('cycle-calendar-view button switches to month view', () => {
187
+ it('cycle-calendar-view button opens the month-year split view', () => {
188
188
  cy.mount(DateTimePicker)
189
189
  cy.get('input').first().dblclick()
190
190
  cy.get('[aria-label=cycle-calendar-view]').click()
191
- cy.get('[role=grid][aria-label="Select month"]').should('exist')
191
+ // Split view: scrollable year list beside a scrollable month list.
192
+ cy.get('[role=listbox][aria-label="Select year"]').should('exist')
193
+ cy.get('[role=listbox][aria-label="Select month"]').should('exist')
192
194
  })
193
195
  })
@@ -61,8 +61,6 @@
61
61
  :view="view"
62
62
  :current-year="currentYear"
63
63
  :current-month="currentMonth"
64
- :year-range-start="yearRangeStart"
65
- :year-range="yearRange"
66
64
  :weeks="weeks"
67
65
  today-label="Now"
68
66
  :min="resolvedMin"
@@ -232,8 +230,6 @@ const {
232
230
  view,
233
231
  currentYear,
234
232
  currentMonth,
235
- yearRangeStart,
236
- yearRange,
237
233
  prev,
238
234
  next,
239
235
  cycleView,
@@ -16,57 +16,37 @@ export function useCalendarView() {
16
16
  const currentYear = ref<number>(dayjs().year())
17
17
  const currentMonth = ref<number>(dayjs().month())
18
18
 
19
- const yearRangeStart = computed(
20
- () => currentYear.value - (currentYear.value % 12),
21
- )
22
- const yearRange = computed<number[]>(() =>
23
- Array.from({ length: 12 }, (_, i) => yearRangeStart.value + i),
24
- )
25
-
19
+ // Prev/next only navigate months in the day grid. The month-year split view
20
+ // scrolls instead of paging, so it has no prev/next controls.
26
21
  function prev(): void {
27
- if (view.value === 'date') {
28
- const m = monthStart(currentYear.value, currentMonth.value).subtract(
29
- 1,
30
- 'month',
31
- )
32
- currentYear.value = m.year()
33
- currentMonth.value = m.month()
34
- } else if (view.value === 'month') {
35
- currentYear.value -= 1
36
- } else {
37
- currentYear.value -= 12
38
- }
22
+ const m = monthStart(currentYear.value, currentMonth.value).subtract(
23
+ 1,
24
+ 'month',
25
+ )
26
+ currentYear.value = m.year()
27
+ currentMonth.value = m.month()
39
28
  }
40
29
 
41
30
  function next(): void {
42
- if (view.value === 'date') {
43
- const m = monthStart(currentYear.value, currentMonth.value).add(
44
- 1,
45
- 'month',
46
- )
47
- currentYear.value = m.year()
48
- currentMonth.value = m.month()
49
- } else if (view.value === 'month') {
50
- currentYear.value += 1
51
- } else {
52
- currentYear.value += 12
53
- }
31
+ const m = monthStart(currentYear.value, currentMonth.value).add(1, 'month')
32
+ currentYear.value = m.year()
33
+ currentMonth.value = m.month()
54
34
  }
55
35
 
36
+ // Toggle between the day grid and the month-year split view.
56
37
  function cycleView(): void {
57
- if (view.value === 'date') view.value = 'month'
58
- else if (view.value === 'month') view.value = 'year'
59
- else view.value = 'date'
38
+ view.value = view.value === 'date' ? 'monthYear' : 'date'
60
39
  }
61
40
 
41
+ // Picking a month commits the choice and returns to the day grid.
62
42
  function selectMonth(i: number): void {
63
43
  currentMonth.value = i
64
44
  view.value = 'date'
65
45
  }
66
46
 
47
+ // Picking a year stays in the split view so the user can then pick a month.
67
48
  function selectYear(y: number): void {
68
49
  currentYear.value = y
69
- view.value = 'month'
70
50
  }
71
51
 
72
52
  function focusOn(d: Dayjs): void {
@@ -82,8 +62,6 @@ export function useCalendarView() {
82
62
  view,
83
63
  currentYear,
84
64
  currentMonth,
85
- yearRangeStart,
86
- yearRange,
87
65
  prev,
88
66
  next,
89
67
  cycleView,
@@ -303,7 +303,7 @@ export interface DateTimePickerSlots {
303
303
  actions?: (props: DateTimePickerActionsSlotProps) => any
304
304
  }
305
305
 
306
- export type DatePickerViewMode = 'date' | 'month' | 'year'
306
+ export type DatePickerViewMode = 'date' | 'monthYear'
307
307
 
308
308
  export interface DatePickerDateObj {
309
309
  date: Dayjs
@@ -3,8 +3,8 @@
3
3
  <PopoverAnchor asChild>
4
4
  <div
5
5
  ref="anchorRef"
6
- :class="['flex', $attrs.class]"
7
- :style="($attrs.style as StyleValue)"
6
+ v-bind="$attrs"
7
+ class="flex"
8
8
  @mouseover="onMouseover"
9
9
  @mouseleave="onMouseleave"
10
10
  >
@@ -65,7 +65,7 @@
65
65
  </template>
66
66
 
67
67
  <script setup lang="ts">
68
- import { computed, ref, onUnmounted, type StyleValue } from 'vue'
68
+ import { computed, ref, onUnmounted } from 'vue'
69
69
  import {
70
70
  PopoverAnchor,
71
71
  PopoverContent,
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <PopoverRoot v-model:open="isOpen">
3
3
  <PopoverAnchor :reference="anchorEl" as-child>
4
- <div>
4
+ <div v-bind="$attrs">
5
5
  <TextInput
6
6
  ref="inputRef"
7
7
  v-model="displayValue"
@@ -157,6 +157,8 @@ defineSlots<{
157
157
  suffix?: (props: { togglePopover: () => void; isOpen: boolean }) => any
158
158
  }>()
159
159
 
160
+ defineOptions({ inheritAttrs: false })
161
+
160
162
  // ── Prop reconciliation (new wins; legacy aliases preserved with warnings) ──
161
163
 
162
164
  const resolvedSide = computed<PopoverSide>(
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <PopoverRoot v-model:open="open">
3
3
  <PopoverAnchor :reference="anchorEl" as-child>
4
- <div @keydown.down.prevent="onArrowDown">
4
+ <div v-bind="$attrs" @keydown.down.prevent="onArrowDown">
5
5
  <slot name="trigger" v-bind="triggerSlotProps">
6
6
  <slot name="target" v-bind="triggerSlotProps">
7
7
  <TextInput
@@ -145,6 +145,8 @@ const slots = defineSlots<{
145
145
  default?: (props: { close: () => void }) => any
146
146
  }>()
147
147
 
148
+ defineOptions({ inheritAttrs: false })
149
+
148
150
  const open = defineModel<boolean>('open', { default: false })
149
151
  const inputValue = defineModel<string>('inputValue', { default: '' })
150
152
  const typing = defineModel<boolean>('typing', { default: false })