@budibase/bbui 3.41.3 → 3.42.0

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@budibase/bbui",
3
3
  "description": "A UI solution used in the different Budibase projects.",
4
- "version": "3.41.3",
4
+ "version": "v3.42.0",
5
5
  "license": "MPL-2.0",
6
6
  "module": "dist/bbui.mjs",
7
7
  "exports": {
@@ -108,5 +108,5 @@
108
108
  }
109
109
  }
110
110
  },
111
- "gitHead": "dd1b8d888c49ad09b82b00fc5b93ceff707bbd6a"
111
+ "gitHead": "180c04c6852aa7acf0402d53dc909eab764d0251"
112
112
  }
@@ -23,6 +23,9 @@
23
23
  export let active = false
24
24
  export let tooltip: string | null = ""
25
25
  export let tooltipPosition: TooltipPosition = TooltipPosition.Top
26
+ export let calculateTooltipWidth:
27
+ | ((target: Element) => number | undefined)
28
+ | undefined = undefined
26
29
  export let newStyles = true
27
30
  export let id: string | undefined = undefined
28
31
  export let ref: HTMLButtonElement | undefined = undefined
@@ -32,10 +35,16 @@
32
35
  $: tooltipText = tooltip ?? ""
33
36
  </script>
34
37
 
35
- <AbsTooltip text={tooltipText} position={tooltipPosition}>
38
+ <AbsTooltip
39
+ text={tooltipText}
40
+ position={tooltipPosition}
41
+ disabledTarget={disabled && !!tooltipText}
42
+ calculateWidth={calculateTooltipWidth}
43
+ >
36
44
  <button
37
45
  {id}
38
46
  {type}
47
+ {disabled}
39
48
  bind:this={ref}
40
49
  class:spectrum-Button--cta={cta}
41
50
  class:spectrum-Button--primary={primary}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { UIEvent } from "@budibase/types"
2
+ import type { UIEvent } from "../types"
3
3
  import { createEventDispatcher, onMount } from "svelte"
4
4
  import { fade } from "svelte/transition"
5
5
  import FancyField from "./FancyField.svelte"
@@ -1,3 +1,10 @@
1
+ <script lang="ts" module>
2
+ export interface DatePickerApi {
3
+ open: () => void
4
+ close: () => void
5
+ }
6
+ </script>
7
+
1
8
  <script lang="ts" generics="V">
2
9
  import "@spectrum-css/calendar/dist/index-vars.css"
3
10
  import "@spectrum-css/inputgroup/dist/index-vars.css"
@@ -12,18 +19,19 @@
12
19
  import { getLocaleStartDayOfWeek, type Weekday } from "./utils"
13
20
  import { resolveTranslationGroup } from "@budibase/shared-core"
14
21
 
15
- export let id = null
22
+ export let id: string | null = null
16
23
  export let disabled = false
17
24
  export let readonly = false
18
- export let error = null
25
+ export let error: string | false | null | undefined = null
19
26
  export let enableTime = true
20
27
  export let value: V | null = null
21
28
  export let placeholder: string | null = null
22
29
  export let timeOnly = false
30
+ export let setTimeTo: string | undefined = undefined
23
31
  export let ignoreTimezones = false
24
32
  export let useKeyboardShortcuts = true
25
- export let appendTo = undefined
26
- export let api = null
33
+ export let appendTo: string | undefined = undefined
34
+ export let api: DatePickerApi | null = null
27
35
  export let align: PopoverAlignment = PopoverAlignment.Left
28
36
  const browserStartDayOfWeek = getLocaleStartDayOfWeek()
29
37
  export let startDayOfWeek: Weekday | undefined = undefined
@@ -37,6 +45,7 @@
37
45
 
38
46
  $: parsedValue = parseDate(value as string | dayjs.Dayjs | null, {
39
47
  enableTime,
48
+ setTimeTo,
40
49
  })
41
50
 
42
51
  const onOpen = () => {
@@ -88,6 +97,7 @@
88
97
  {ignoreTimezones}
89
98
  {enableTime}
90
99
  {timeOnly}
100
+ {setTimeTo}
91
101
  startDayOfWeek={resolvedStartDayOfWeek}
92
102
  {calendarLabels}
93
103
  value={parsedValue}
@@ -1,22 +1,24 @@
1
- <script>
2
- import dayjs from "dayjs"
3
- import TimePicker from "./TimePicker.svelte"
4
- import Calendar from "./Calendar.svelte"
5
- import ActionButton from "../../../ActionButton/ActionButton.svelte"
1
+ <script lang="ts">
2
+ import dayjs, { type Dayjs } from "dayjs"
6
3
  import { createEventDispatcher, onMount } from "svelte"
7
- import { stringifyDate } from "../../../helpers"
8
4
  import { resolveTranslationGroup } from "@budibase/shared-core"
5
+ import ActionButton from "../../../ActionButton/ActionButton.svelte"
6
+ import { stringifyDate } from "../../../helpers"
7
+ import Calendar from "./Calendar.svelte"
8
+ import TimePicker from "./TimePicker.svelte"
9
+ import type { Weekday } from "./utils"
9
10
 
10
11
  export let useKeyboardShortcuts = true
11
- export let ignoreTimezones
12
- export let enableTime
13
- export let timeOnly
14
- export let value
15
- export let startDayOfWeek = "Monday"
12
+ export let ignoreTimezones = false
13
+ export let enableTime = true
14
+ export let timeOnly = false
15
+ export let setTimeTo: string | undefined = undefined
16
+ export let value: Dayjs | null | undefined = null
17
+ export let startDayOfWeek: Weekday = "Monday"
16
18
  export let calendarLabels = resolveTranslationGroup("calendar")
17
19
 
18
- const dispatch = createEventDispatcher()
19
- let calendar
20
+ const dispatch = createEventDispatcher<{ change: string | null }>()
21
+ let calendar: { setDate: (date: Dayjs) => void } | undefined
20
22
 
21
23
  $: showCalendar = !timeOnly
22
24
  $: showTime = enableTime || timeOnly
@@ -27,14 +29,19 @@
27
29
  handleChange(now)
28
30
  }
29
31
 
30
- const handleChange = date => {
32
+ const handleChange = (date: Dayjs | null | undefined) => {
31
33
  dispatch(
32
34
  "change",
33
- stringifyDate(date, { enableTime, timeOnly, ignoreTimezones })
35
+ stringifyDate(date ?? null, {
36
+ enableTime,
37
+ timeOnly,
38
+ ignoreTimezones,
39
+ setTimeTo,
40
+ })
34
41
  )
35
42
  }
36
43
 
37
- const clearDateOnBackspace = event => {
44
+ const clearDateOnBackspace = (event: KeyboardEvent) => {
38
45
  // Ignore if we're typing a value
39
46
  if (document.activeElement?.tagName.toLowerCase() === "input") {
40
47
  return
@@ -1,16 +1,20 @@
1
- <script>
2
- export let value
3
- export let min = undefined
4
- export let max = undefined
1
+ <script lang="ts">
2
+ export let value: string | number | undefined = undefined
3
+ export let min: string | number | undefined = undefined
4
+ export let max: string | number | undefined = undefined
5
5
  export let hideArrows = false
6
- export let width = undefined
6
+ export let width: number | undefined = undefined
7
7
  export let type = "number"
8
8
  export let disabled = false
9
9
  export let readonly = false
10
+ export let step: string | number | undefined = undefined
10
11
 
11
12
  $: style = width ? `width:${width}px;` : ""
12
13
 
13
- const selectAll = event => event.target.select()
14
+ const selectAll = (event: MouseEvent) => {
15
+ const input = event.currentTarget as HTMLInputElement
16
+ input.select()
17
+ }
14
18
  </script>
15
19
 
16
20
  <input
@@ -22,6 +26,7 @@
22
26
  {max}
23
27
  {disabled}
24
28
  {readonly}
29
+ {step}
25
30
  on:click={selectAll}
26
31
  on:change
27
32
  on:input
@@ -4,14 +4,15 @@
4
4
  import NumberInput from "./NumberInput.svelte"
5
5
  import { createEventDispatcher } from "svelte"
6
6
 
7
- export let value: Dayjs | undefined
7
+ export let value: Dayjs | null | undefined
8
8
  export let disableClearing = false
9
9
  export let disabled = false
10
10
  export let readonly = false
11
+ export let showSeconds = false
11
12
 
12
13
  const dispatch = createEventDispatcher<{ change: Dayjs | undefined }>()
13
14
 
14
- $: displayValue = value?.format("HH:mm")
15
+ $: displayValue = value?.format(showSeconds ? "HH:mm:ss" : "HH:mm")
15
16
 
16
17
  const handleChange = async (e: Event) => {
17
18
  if (disabled || readonly) {
@@ -28,10 +29,12 @@
28
29
  return
29
30
  }
30
31
 
31
- const [hour, minute] = target.value.split(":").map(x => parseInt(x))
32
+ const [hour, minute, second = 0] = target.value
33
+ .split(":")
34
+ .map(x => parseInt(x))
32
35
  dispatch(
33
36
  "change",
34
- (value || dayjs()).hour(hour).minute(minute).second(0).millisecond(0)
37
+ (value || dayjs()).hour(hour).minute(minute).second(second).millisecond(0)
35
38
  )
36
39
  }
37
40
  </script>
@@ -43,6 +46,7 @@
43
46
  value={displayValue}
44
47
  {disabled}
45
48
  {readonly}
49
+ step={showSeconds ? 1 : undefined}
46
50
  on:input={handleChange}
47
51
  on:change={handleChange}
48
52
  />
@@ -39,7 +39,7 @@
39
39
  }
40
40
  }
41
41
 
42
- const onChangeFrom = (utc: string) => {
42
+ const onChangeFrom = (utc: string | null) => {
43
43
  // Preserve the time if its editable
44
44
  const fromDate = utc
45
45
  ? enableTime
@@ -55,7 +55,7 @@
55
55
  dispatch("change", [fromDate, toDate])
56
56
  }
57
57
 
58
- const onChangeTo = (utc: string) => {
58
+ const onChangeTo = (utc: string | null) => {
59
59
  // Preserve the time if its editable
60
60
  const toDate = utc
61
61
  ? enableTime
@@ -2,7 +2,7 @@
2
2
  import "@spectrum-css/textfield/dist/index-vars.css"
3
3
  import { createEventDispatcher, onMount, tick } from "svelte"
4
4
  import type { FullAutoFill } from "svelte/elements"
5
- import type { UIEvent } from "@budibase/types"
5
+ import type { UIEvent } from "../../types"
6
6
 
7
7
  export let value: V | null = null
8
8
  export let placeholder: string | undefined = undefined
@@ -13,6 +13,7 @@
13
13
  export let error = undefined
14
14
  export let enableTime = true
15
15
  export let timeOnly = false
16
+ export let setTimeTo: string | undefined = undefined
16
17
  export let placeholder: string | null = null
17
18
  export let appendTo = undefined
18
19
  export let ignoreTimezones = false
@@ -36,6 +37,7 @@
36
37
  {placeholder}
37
38
  {enableTime}
38
39
  {timeOnly}
40
+ {setTimeTo}
39
41
  {appendTo}
40
42
  {ignoreTimezones}
41
43
  {calendarLabels}
@@ -16,6 +16,7 @@
16
16
  id?: string
17
17
  name?: string
18
18
  disableClearing?: boolean
19
+ showSeconds?: boolean
19
20
  onchange?: (value: string | undefined) => void
20
21
  }
21
22
  </script>
@@ -39,33 +40,41 @@
39
40
  id = undefined,
40
41
  name = undefined,
41
42
  disableClearing = false,
43
+ showSeconds = false,
42
44
  onchange,
43
45
  }: Props = $props()
44
46
 
45
- const FALLBACK_TIME = "00:00"
47
+ const FALLBACK_TIME = $derived(showSeconds ? "00:00:00" : "00:00")
46
48
 
47
49
  const parseValue = (time: TimeFieldValue) => {
48
50
  if (!time) {
49
51
  return undefined
50
52
  }
51
53
 
52
- const [hour, minute] = time.split(":").map(part => Number(part))
54
+ const parts = time.split(":")
55
+ if (parts.length !== (showSeconds ? 3 : 2)) {
56
+ return undefined
57
+ }
58
+ const [hour, minute, second = 0] = parts.map(part => Number(part))
53
59
  if (
54
60
  !Number.isInteger(hour) ||
55
61
  !Number.isInteger(minute) ||
62
+ !Number.isInteger(second) ||
56
63
  hour < 0 ||
57
64
  hour > 23 ||
58
65
  minute < 0 ||
59
- minute > 59
66
+ minute > 59 ||
67
+ second < 0 ||
68
+ second > 59
60
69
  ) {
61
70
  return undefined
62
71
  }
63
72
 
64
- return dayjs().hour(hour).minute(minute).second(0).millisecond(0)
73
+ return dayjs().hour(hour).minute(minute).second(second).millisecond(0)
65
74
  }
66
75
 
67
76
  const handleChange = (event: CustomEvent<Dayjs | undefined>) => {
68
- const nextValue = event.detail?.format("HH:mm")
77
+ const nextValue = event.detail?.format(showSeconds ? "HH:mm:ss" : "HH:mm")
69
78
  if (!nextValue && disableClearing) {
70
79
  return
71
80
  }
@@ -104,6 +113,7 @@
104
113
  {disableClearing}
105
114
  {disabled}
106
115
  {readonly}
116
+ {showSeconds}
107
117
  on:change={handleChange}
108
118
  />
109
119
  {#if name}
@@ -11,12 +11,17 @@
11
11
  export let fixed: boolean = false
12
12
  export let color: string | undefined = undefined
13
13
  export let noWrap: boolean = false
14
+ export let disabledTarget: boolean = false
15
+ export let calculateWidth:
16
+ | ((target: Element) => number | undefined)
17
+ | undefined = undefined
14
18
  export let offset: number = 0
15
19
 
16
20
  let wrapper: HTMLElement | undefined
17
21
  let hovered = false
18
22
  let left: number | undefined
19
23
  let top: number | undefined
24
+ let tooltipWidth: number | undefined
20
25
  let visible = false
21
26
  let timeout: ReturnType<typeof setTimeout> | undefined
22
27
  let interval: ReturnType<typeof setInterval> | undefined
@@ -38,9 +43,11 @@
38
43
  if (!node) {
39
44
  left = undefined
40
45
  top = undefined
46
+ tooltipWidth = undefined
41
47
  return
42
48
  }
43
49
  const bounds = node.getBoundingClientRect()
50
+ tooltipWidth = calculateWidth?.(node)
44
51
 
45
52
  // Determine where to render tooltip based on position prop
46
53
  if (position === TooltipPosition.Top) {
@@ -82,6 +89,7 @@
82
89
  <div
83
90
  bind:this={wrapper}
84
91
  class="abs-tooltip"
92
+ class:disabled-target={disabledTarget}
85
93
  on:focus={null}
86
94
  on:mouseover={() => (hovered = true)}
87
95
  on:mouseleave={() => (hovered = false)}
@@ -94,7 +102,13 @@
94
102
  <span
95
103
  class="spectrum-Tooltip spectrum-Tooltip--{type} spectrum-Tooltip--{position} is-open"
96
104
  class:noWrap
97
- style={`left:${left}px;top:${top}px;${tooltipStyle}`}
105
+ class:calculated-width={tooltipWidth != null}
106
+ style:left={`${left}px`}
107
+ style:top={`${top}px`}
108
+ style:--tooltip-width={tooltipWidth != null
109
+ ? `${tooltipWidth}px`
110
+ : undefined}
111
+ style={tooltipStyle}
98
112
  transition:fade|local={{ duration: 130 }}
99
113
  >
100
114
  <span class="spectrum-Tooltip-label">{text}</span>
@@ -108,6 +122,12 @@
108
122
  display: contents;
109
123
  pointer-events: all;
110
124
  }
125
+ .abs-tooltip.disabled-target {
126
+ display: inline-block;
127
+ }
128
+ .abs-tooltip.disabled-target :global(:disabled) {
129
+ pointer-events: none;
130
+ }
111
131
  .spectrum-Tooltip.noWrap .spectrum-Tooltip-label {
112
132
  width: max-content;
113
133
  }
@@ -121,6 +141,11 @@
121
141
  top 130ms ease-out,
122
142
  left 130ms ease-out;
123
143
  }
144
+ .spectrum-Tooltip.calculated-width {
145
+ width: var(--tooltip-width);
146
+ max-width: none;
147
+ max-inline-size: none;
148
+ }
124
149
  .spectrum-Tooltip-label {
125
150
  display: -webkit-box;
126
151
  -webkit-line-clamp: 3;
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest"
2
- import { uuid } from "./helpers"
2
+ import dayjs from "dayjs"
3
+ import { parseDate, parseTime, stringifyDate, uuid } from "./helpers"
3
4
 
4
5
  describe("uuid", () => {
5
6
  afterEach(() => {
@@ -27,3 +28,58 @@ describe("uuid", () => {
27
28
  expect(Math.random).toHaveBeenCalled()
28
29
  })
29
30
  })
31
+
32
+ describe("date helpers", () => {
33
+ const selectedDate = dayjs("2026-08-05T10:15:30.456")
34
+
35
+ it("keeps date-only values unchanged when no time is configured", () => {
36
+ expect(
37
+ stringifyDate(selectedDate, { enableTime: false, setTimeTo: "" })
38
+ ).toBe("2026-08-05")
39
+ expect(stringifyDate(null, { setTimeTo: "23:59:59" })).toBeNull()
40
+ })
41
+
42
+ it("applies a configured time to timezone-aware date selections", () => {
43
+ const expected = selectedDate
44
+ .hour(23)
45
+ .minute(59)
46
+ .second(59)
47
+ .millisecond(0)
48
+ .toISOString()
49
+
50
+ expect(
51
+ stringifyDate(selectedDate, {
52
+ enableTime: false,
53
+ setTimeTo: "23:59:59",
54
+ })
55
+ ).toBe(expected)
56
+ })
57
+
58
+ it("applies a configured time without an offset when ignoring timezones", () => {
59
+ expect(
60
+ stringifyDate(selectedDate, {
61
+ enableTime: false,
62
+ ignoreTimezones: true,
63
+ setTimeTo: "23:59:59",
64
+ })
65
+ ).toBe("2026-08-05T23:59:59.000")
66
+ })
67
+
68
+ it("treats malformed configured times as unset", () => {
69
+ expect(
70
+ stringifyDate(selectedDate, {
71
+ enableTime: false,
72
+ setTimeTo: "24:00:00",
73
+ })
74
+ ).toBe("2026-08-05")
75
+ expect(parseTime("12:30")).toBeNull()
76
+ })
77
+
78
+ it("parses stored datetime values without discarding their time", () => {
79
+ const value = "2026-08-05T23:59:59.000Z"
80
+
81
+ expect(
82
+ parseDate(value, { enableTime: false, setTimeTo: "23:59:59" })?.valueOf()
83
+ ).toBe(dayjs(value).valueOf())
84
+ })
85
+ })
package/src/helpers.ts CHANGED
@@ -119,11 +119,28 @@ export const copyToClipboard = (value: any): Promise<void> => {
119
119
  })
120
120
  }
121
121
 
122
+ export const parseTime = (
123
+ value: string | undefined
124
+ ): { hour: number; minute: number; second: number } | null => {
125
+ const match = /^(\d{2}):(\d{2}):(\d{2})$/.exec(value || "")
126
+ if (!match) {
127
+ return null
128
+ }
129
+ const [, hourValue, minuteValue, secondValue] = match
130
+ const hour = Number(hourValue)
131
+ const minute = Number(minuteValue)
132
+ const second = Number(secondValue)
133
+ if (hour > 23 || minute > 59 || second > 59) {
134
+ return null
135
+ }
136
+ return { hour, minute, second }
137
+ }
138
+
122
139
  // Parse a date value. This is usually an ISO string, but can be a
123
140
  // bunch of different formats and shapes depending on schema flags.
124
141
  export const parseDate = (
125
142
  value: string | dayjs.Dayjs | null,
126
- { enableTime = true }
143
+ { enableTime = true, setTimeTo }: { enableTime?: boolean; setTimeTo?: string }
127
144
  ): dayjs.Dayjs | null => {
128
145
  // If empty then invalid
129
146
  if (!value) {
@@ -138,7 +155,7 @@ export const parseDate = (
138
155
  }
139
156
 
140
157
  // If date only, check for cases where we received a UTC string
141
- else if (!enableTime && value.endsWith("Z")) {
158
+ else if (!enableTime && !parseTime(setTimeTo) && value.endsWith("Z")) {
142
159
  value = value.split("Z")[0]
143
160
  }
144
161
  }
@@ -159,16 +176,36 @@ export const parseDate = (
159
176
  // schema flags
160
177
  export const stringifyDate = (
161
178
  value: null | dayjs.Dayjs,
162
- { enableTime = true, timeOnly = false, ignoreTimezones = false } = {}
179
+ {
180
+ enableTime = true,
181
+ timeOnly = false,
182
+ ignoreTimezones = false,
183
+ setTimeTo,
184
+ }: {
185
+ enableTime?: boolean
186
+ timeOnly?: boolean
187
+ ignoreTimezones?: boolean
188
+ setTimeTo?: string
189
+ } = {}
163
190
  ): string | null => {
164
191
  if (!value) {
165
192
  return null
166
193
  }
167
194
 
195
+ const configuredTime = !enableTime && !timeOnly ? parseTime(setTimeTo) : null
196
+ if (configuredTime) {
197
+ value = value
198
+ .hour(configuredTime.hour)
199
+ .minute(configuredTime.minute)
200
+ .second(configuredTime.second)
201
+ .millisecond(0)
202
+ }
203
+ const serializeTime = enableTime || !!configuredTime
204
+
168
205
  // Time only fields always ignore timezones, otherwise they make no sense.
169
206
  // For non-timezone-aware fields, create an ISO 8601 timestamp of the exact
170
207
  // time picked, without timezone
171
- const offsetForTimezone = (enableTime && ignoreTimezones) || timeOnly
208
+ const offsetForTimezone = (serializeTime && ignoreTimezones) || timeOnly
172
209
  if (offsetForTimezone) {
173
210
  // Ensure we use the correct offset for the date
174
211
  const referenceDate = value.toDate()
@@ -183,7 +220,7 @@ export const stringifyDate = (
183
220
 
184
221
  // For date-only fields, construct a manual timestamp string without a time
185
222
  // or time zone
186
- else if (!enableTime) {
223
+ else if (!serializeTime) {
187
224
  const year = value.year()
188
225
  const month = `${value.month() + 1}`.padStart(2, "0")
189
226
  const day = `${value.date()}`.padStart(2, "0")
@@ -0,0 +1,2 @@
1
+ declare module "*.css"
2
+ declare module "@spectrum-css/*"
@@ -0,0 +1,5 @@
1
+ export interface UIEvent extends Omit<Event, "target"> {
2
+ currentTarget: EventTarget & HTMLInputElement
3
+ key?: string
4
+ target?: any
5
+ }
@@ -2,5 +2,6 @@ export * from "./actionMenu"
2
2
  export * from "./envDropdown"
3
3
  export * from "./modalContext"
4
4
  export * from "./scrollContext"
5
+ export * from "./events"
5
6
 
6
7
  export type LabelPosition = "above" | "left"