@budibase/bbui 3.41.3 → 3.43.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.
@@ -3,16 +3,19 @@
3
3
  import AbsTooltip from "../Tooltip/AbsTooltip.svelte"
4
4
  import ActionButton from "../ActionButton/ActionButton.svelte"
5
5
 
6
- export let leftIcon: string
6
+ export let leftIcon: string | undefined = undefined
7
7
  export let leftNotificationTooltip: string | undefined = undefined
8
8
  export let leftNotificationCount: number | undefined = undefined
9
9
  export let leftText: string
10
- export let rightIcon: string
10
+ export let rightIcon: string | undefined = undefined
11
11
  export let rightNotificationTooltip: string | undefined = undefined
12
12
  export let rightNotificationCount: number | undefined = undefined
13
13
  export let rightText: string
14
14
  export let selected: "left" | "right" = "left"
15
15
  export let disabled = false
16
+ export let leftDisabled = false
17
+ export let rightDisabled = false
18
+ export let size: "S" | "M" | "L" = "M"
16
19
 
17
20
  const dispatch = createEventDispatcher<{
18
21
  left: void
@@ -21,7 +24,7 @@
21
24
  </script>
22
25
 
23
26
  <div class="view-mode-toggle" class:disabled>
24
- <div class="group">
27
+ <div class="group size-{size}">
25
28
  <div class="wrapper">
26
29
  {#if leftNotificationTooltip && leftNotificationCount}
27
30
  <AbsTooltip text={leftNotificationTooltip}>
@@ -39,7 +42,8 @@
39
42
  <ActionButton
40
43
  icon={leftIcon}
41
44
  quiet
42
- {disabled}
45
+ disabled={disabled || leftDisabled}
46
+ {size}
43
47
  selected={selected === "left"}
44
48
  on:click={() => {
45
49
  selected = "left"
@@ -67,7 +71,8 @@
67
71
  <ActionButton
68
72
  icon={rightIcon}
69
73
  quiet
70
- {disabled}
74
+ disabled={disabled || rightDisabled}
75
+ {size}
71
76
  selected={selected === "right"}
72
77
  on:click={() => {
73
78
  selected = "right"
@@ -101,6 +106,16 @@
101
106
  .left :global(*) {
102
107
  border-radius: 10px 0 0 10px;
103
108
  }
109
+ /* A radius sized for M crops the label on a shorter button */
110
+ .group.size-S {
111
+ border-radius: 8px;
112
+ }
113
+ .size-S .right :global(*) {
114
+ border-radius: 0 6px 6px 0;
115
+ }
116
+ .size-S .left :global(*) {
117
+ border-radius: 6px 0 0 6px;
118
+ }
104
119
  .wrapper {
105
120
  position: relative;
106
121
  }
@@ -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")
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import "./bbui.css"
2
2
 
3
3
  // Constants
4
4
  export * from "./constants"
5
+ export { generateId } from "./utils/ids"
5
6
 
6
7
  // Form components
7
8
  export { default as Checkbox } from "./Form/Checkbox.svelte"
@@ -122,5 +123,4 @@ export { createNotificationStore, notifications } from "./Stores/notifications"
122
123
 
123
124
  // Helpers
124
125
  export * as Helpers from "./helpers"
125
-
126
126
  export type * from "./types"
@@ -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"
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { generateId } from "./ids"
3
+
4
+ const ID_PATTERN = /^[0-9a-zA-Z_-]+$/
5
+
6
+ describe("generateId", () => {
7
+ it("uses the configured length and character set", () => {
8
+ const ids = Array.from({ length: 100 }, generateId)
9
+
10
+ expect(ids.every(id => id.length === 9)).toBe(true)
11
+ expect(ids.every(id => ID_PATTERN.test(id))).toBe(true)
12
+ })
13
+ })
@@ -0,0 +1,5 @@
1
+ import { nanoid } from "nanoid"
2
+
3
+ const ID_LENGTH = 9
4
+
5
+ export const generateId = (): string => nanoid(ID_LENGTH)