@leavepulse/ui 0.37.0 → 0.37.2

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,7 +3,7 @@ import { _ as _sfc_main$2 } from "./LpButton-5VXXCjwF.js";
3
3
  import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
4
4
  import { _ as _sfc_main$5 } from "./LpScrollArea-D8xyKXcF.js";
5
5
  import { _ as _sfc_main$4 } from "./LpShift-DXNsauQZ.js";
6
- import { _ as _sfc_main$1 } from "./LpSidebar-DcYavim2.js";
6
+ import { _ as _sfc_main$1 } from "./LpSidebar-DvGfofv6.js";
7
7
  import { useMergedAttrs } from "../composables/useMergedClass.js";
8
8
  const _hoisted_1 = { class: "flex min-w-0 flex-1 flex-col" };
9
9
  const _hoisted_2 = { class: "relative flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-4 md:px-6" };
@@ -5,6 +5,11 @@ import { _ as _sfc_main$2 } from "./LpCalendar-B7Ko5RdR.js";
5
5
  import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
6
6
  const _hoisted_1 = ["disabled"];
7
7
  const _hoisted_2 = ["onKeydown"];
8
+ const _hoisted_3 = {
9
+ key: 0,
10
+ class: "flex items-center gap-2 border-t border-line px-3 py-2"
11
+ };
12
+ const _hoisted_4 = ["value", "disabled"];
8
13
  const _sfc_main = /* @__PURE__ */ defineComponent({
9
14
  __name: "LpDatePicker",
10
15
  props: {
@@ -17,18 +22,27 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
17
22
  disabled: { type: Boolean },
18
23
  invalid: { type: Boolean },
19
24
  size: { default: "md" },
20
- format: { default: () => ({ year: "numeric", month: "short", day: "numeric" }) }
25
+ format: { default: () => ({ year: "numeric", month: "short", day: "numeric" }) },
26
+ time: { type: Boolean },
27
+ defaultTime: { default: "00:00" },
28
+ doneLabel: { default: "Done" }
21
29
  },
22
30
  emits: ["update:modelValue"],
23
31
  setup(__props, { emit: __emit }) {
24
32
  const props = __props;
25
33
  const emit = __emit;
26
34
  const open = ref(false);
35
+ const day = computed(() => (props.modelValue || "").slice(0, 10));
36
+ const minute = computed(() => {
37
+ const at = (props.modelValue || "").slice(11, 16);
38
+ return /^\d{2}:\d{2}$/.test(at) ? at : "";
39
+ });
27
40
  const display = computed(() => {
28
41
  if (!props.modelValue) return "";
29
- const [y, m, d] = props.modelValue.split("-").map(Number);
42
+ const [y, m, d] = day.value.split("-").map(Number);
30
43
  if (!y || !m || !d) return props.modelValue;
31
- return new Intl.DateTimeFormat(void 0, props.format).format(new Date(y, m - 1, d));
44
+ const shown = new Intl.DateTimeFormat(void 0, props.format).format(new Date(y, m - 1, d));
45
+ return props.time && minute.value ? `${shown}, ${minute.value}` : shown;
32
46
  });
33
47
  const shellSize = {
34
48
  sm: "h-(--size-control-sm) text-xs",
@@ -36,8 +50,16 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
36
50
  lg: "h-(--size-control-lg) text-sm"
37
51
  };
38
52
  function onPick(v) {
39
- emit("update:modelValue", v);
40
- open.value = false;
53
+ if (!props.time) {
54
+ emit("update:modelValue", v);
55
+ open.value = false;
56
+ return;
57
+ }
58
+ emit("update:modelValue", v ? `${v}T${minute.value || props.defaultTime}` : void 0);
59
+ }
60
+ function onTime(at) {
61
+ if (!day.value) return;
62
+ emit("update:modelValue", `${day.value}T${at || props.defaultTime}`);
41
63
  }
42
64
  function clear() {
43
65
  emit("update:modelValue", void 0);
@@ -45,7 +67,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
45
67
  return (_ctx, _cache) => {
46
68
  return openBlock(), createBlock(unref(PopoverRoot), {
47
69
  open: open.value,
48
- "onUpdate:open": _cache[0] || (_cache[0] = ($event) => open.value = $event)
70
+ "onUpdate:open": _cache[2] || (_cache[2] = ($event) => open.value = $event)
49
71
  }, {
50
72
  default: withCtx(() => [
51
73
  createVNode(unref(PopoverTrigger), { "as-child": "" }, {
@@ -98,13 +120,32 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
98
120
  }, {
99
121
  default: withCtx(() => [
100
122
  createVNode(_sfc_main$2, {
101
- "model-value": __props.modelValue,
123
+ "model-value": day.value,
102
124
  min: __props.min,
103
125
  max: __props.max,
104
126
  "is-disabled": __props.isDisabled,
105
127
  class: "border-0 bg-transparent",
106
128
  "onUpdate:modelValue": onPick
107
- }, null, 8, ["model-value", "min", "max", "is-disabled"])
129
+ }, null, 8, ["model-value", "min", "max", "is-disabled"]),
130
+ __props.time ? (openBlock(), createElementBlock("div", _hoisted_3, [
131
+ createVNode(_sfc_main$1, {
132
+ name: "lucide:clock",
133
+ size: 15,
134
+ class: "shrink-0 text-muted"
135
+ }),
136
+ createElementVNode("input", {
137
+ type: "time",
138
+ value: minute.value,
139
+ disabled: !day.value,
140
+ class: "h-(--size-control-sm) flex-1 rounded-control border border-line bg-surface-soft px-2 text-sm text-ink outline-none transition-colors focus-visible:border-brand focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-55",
141
+ onInput: _cache[0] || (_cache[0] = ($event) => onTime($event.target.value))
142
+ }, null, 40, _hoisted_4),
143
+ createElementVNode("button", {
144
+ type: "button",
145
+ class: "shrink-0 rounded-control px-2 py-1 text-xs text-muted transition-colors hover:text-ink",
146
+ onClick: _cache[1] || (_cache[1] = ($event) => open.value = false)
147
+ }, toDisplayString(__props.doneLabel), 1)
148
+ ])) : createCommentVNode("", true)
108
149
  ]),
109
150
  _: 1
110
151
  }, 8, ["class"])
@@ -1,9 +1,10 @@
1
- import { defineComponent, computed, ref, useSlots, openBlock, createElementBlock, Fragment, createElementVNode, normalizeStyle, normalizeClass, withDirectives, vShow, toDisplayString, createCommentVNode, createVNode, unref, withCtx, createBlock, renderSlot, createTextVNode } from "vue";
1
+ import { defineComponent, computed, watch, onBeforeUnmount, ref, useSlots, openBlock, createElementBlock, Fragment, createElementVNode, normalizeStyle, normalizeClass, withDirectives, vShow, toDisplayString, createCommentVNode, createVNode, unref, withCtx, createBlock, renderSlot, createTextVNode } from "vue";
2
2
  import { usePointerSwipe } from "@vueuse/core";
3
3
  import { DrawerRoot, DrawerPortal, DrawerOverlay, DrawerContent, DrawerHandle, DrawerTitle, DrawerDescription, DrawerClose } from "vaul-vue";
4
4
  import { CLOSE_ICON } from "../components/dropdown.js";
5
5
  import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
6
6
  import { _ as _sfc_main$2 } from "./LpScrollArea-D8xyKXcF.js";
7
+ import { useModalLayer } from "../components/modalDepth.js";
7
8
  const _hoisted_1 = { class: "text-base font-semibold text-ink" };
8
9
  const _hoisted_2 = { class: "flex flex-col gap-1" };
9
10
  const _hoisted_3 = ["data-vaul-no-drag"];
@@ -47,6 +48,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
47
48
  const isSheet = computed(() => dir.value === "top" || dir.value === "bottom");
48
49
  const showHandle = computed(() => props.handle ?? isSheet.value);
49
50
  const SIZES = { sm: 22, md: 28, lg: 36, xl: 48 };
51
+ const { claim, release, layer } = useModalLayer();
52
+ watch(() => props.open, (open) => open ? claim() : release(), { immediate: true });
53
+ onBeforeUnmount(release);
50
54
  const contentClass = computed(() => {
51
55
  const base = "fixed z-(--z-modal) flex flex-col border-line bg-surface-raised shadow-panel outline-none";
52
56
  const map = {
@@ -215,10 +219,13 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
215
219
  default: withCtx(() => [
216
220
  createVNode(unref(DrawerPortal), null, {
217
221
  default: withCtx(() => [
218
- createVNode(unref(DrawerOverlay), { class: "lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]" }),
222
+ createVNode(unref(DrawerOverlay), {
223
+ style: normalizeStyle({ zIndex: unref(layer).scrim }),
224
+ class: "lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
225
+ }, null, 8, ["style"]),
219
226
  createVNode(unref(DrawerContent), {
220
227
  class: normalizeClass(contentClass.value),
221
- style: normalizeStyle(sizeStyle.value),
228
+ style: normalizeStyle([sizeStyle.value, { zIndex: unref(layer).panel }]),
222
229
  "data-vaul-animate": handingOff.value ? "false" : void 0
223
230
  }, {
224
231
  default: withCtx(() => [
@@ -2,7 +2,7 @@ import { defineComponent, ref, computed, openBlock, createBlock, createSlots, wi
2
2
  import { _ as _sfc_main$2 } from "./LpButton-5VXXCjwF.js";
3
3
  import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
4
4
  import { _ as _sfc_main$1 } from "./LpModal-D3QxgWaT.js";
5
- import { _ as _sfc_main$4 } from "./LpSidebar-DcYavim2.js";
5
+ import { _ as _sfc_main$4 } from "./LpSidebar-DvGfofv6.js";
6
6
  const _hoisted_1 = { class: "flex items-center gap-2" };
7
7
  const _sfc_main = /* @__PURE__ */ defineComponent({
8
8
  __name: "LpModalNav",
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, computed, ref, useId, openBlock, createElementBlock, Fragment, createElementVNode, mergeProps, normalizeClass, renderSlot, createCommentVNode, createBlock, createVNode, createSlots, withCtx, normalizeProps, guardReactiveProps } from "vue";
2
- import { _ as _sfc_main$3 } from "./LpDrawer-DAJN5rGK.js";
2
+ import { _ as _sfc_main$3 } from "./LpDrawer-CslZtgUm.js";
3
3
  import { _ as _sfc_main$1 } from "./LpInput-9EXWUM9B.js";
4
4
  import { _ as _sfc_main$2 } from "./LpSidebarNav-CqwvT4H8.js";
5
5
  const _hoisted_1 = {
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAppShell-lVvpENMH.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAppShell-Cs9jDoqJ.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -11,6 +11,12 @@ type __VLS_Props = {
11
11
  size?: "sm" | "md" | "lg";
12
12
  /** Intl format for the displayed date. */
13
13
  format?: Intl.DateTimeFormatOptions;
14
+ /** Also pick a minute; the model becomes "YYYY-MM-DDTHH:mm". */
15
+ time?: boolean;
16
+ /** Time a freshly picked day starts at, "HH:mm". */
17
+ defaultTime?: string;
18
+ /** Label of the button that closes the popover in `time` mode. */
19
+ doneLabel?: string;
14
20
  };
15
21
  declare const __VLS_export: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
16
22
  "update:modelValue": (value: string | undefined) => any;
@@ -20,6 +26,8 @@ declare const __VLS_export: import('vue').DefineComponent<__VLS_Props, {}, {}, {
20
26
  size: "sm" | "md" | "lg";
21
27
  format: Intl.DateTimeFormatOptions;
22
28
  placeholder: string;
29
+ defaultTime: string;
30
+ doneLabel: string;
23
31
  }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
24
32
  declare const _default: typeof __VLS_export;
25
33
  export default _default;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpDatePicker-6JFXllj5.js";
1
+ import { _ as _sfc_main } from "../chunks/LpDatePicker-BPbvu3cZ.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpDrawer-DAJN5rGK.js";
1
+ import { _ as _sfc_main } from "../chunks/LpDrawer-CslZtgUm.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpModalNav-K6N5YpQk.js";
1
+ import { _ as _sfc_main } from "../chunks/LpModalNav-Dkogc3O_.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpSidebar-DcYavim2.js";
1
+ import { _ as _sfc_main } from "../chunks/LpSidebar-DvGfofv6.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,5 +1,5 @@
1
1
  import { ref, computed } from "vue";
2
- const STEP = 10;
2
+ const STEP = 20;
3
3
  const stack = ref([]);
4
4
  let nextId = 0;
5
5
  function useModalLayer() {
@@ -14,6 +14,17 @@ function useShift(options = {}) {
14
14
  let lastWidth = 0;
15
15
  let inFlight = false;
16
16
  let tweenStart = 0;
17
+ const scrolled = /* @__PURE__ */ new Map();
18
+ const rememberScroll = (event) => {
19
+ const target = event.target;
20
+ if (target instanceof Element) scrolled.set(target, target.scrollTop);
21
+ };
22
+ function restoreScroll() {
23
+ for (const [node, top] of scrolled) {
24
+ if (!node.isConnected) scrolled.delete(node);
25
+ else if (node.scrollTop !== top) node.scrollTop = top;
26
+ }
27
+ }
17
28
  const axisOf = () => toValue(options.axis) ?? "height";
18
29
  const wantsHeight = () => axisOf() !== "width";
19
30
  const wantsWidth = () => axisOf() !== "height";
@@ -40,6 +51,7 @@ function useShift(options = {}) {
40
51
  const toWidth = wantsWidth() ? node.offsetWidth : 0;
41
52
  if (wantsHeight()) node.style.maxHeight = "";
42
53
  if (wantsWidth()) node.style.maxWidth = "";
54
+ restoreScroll();
43
55
  lastHeight = toHeight;
44
56
  lastWidth = toWidth;
45
57
  const movesHeight = wantsHeight() && fromHeight !== 0 && toHeight !== fromHeight;
@@ -50,6 +62,7 @@ function useShift(options = {}) {
50
62
  void node.offsetHeight;
51
63
  if (movesHeight) node.style.height = `${toHeight}px`;
52
64
  if (movesWidth) node.style.width = `${toWidth}px`;
65
+ restoreScroll();
53
66
  inFlight = true;
54
67
  tweenStart = performance.now();
55
68
  resizing.value = true;
@@ -64,7 +77,11 @@ function useShift(options = {}) {
64
77
  lastWidth = node.offsetWidth;
65
78
  }, wait);
66
79
  }
80
+ let scrollHost = null;
67
81
  function teardown() {
82
+ scrollHost?.removeEventListener("scroll", rememberScroll, true);
83
+ scrollHost = null;
84
+ scrolled.clear();
68
85
  ro?.disconnect();
69
86
  mo?.disconnect();
70
87
  ro = void 0;
@@ -84,6 +101,8 @@ function useShift(options = {}) {
84
101
  if (!enabled || !node) return;
85
102
  lastHeight = node.offsetHeight;
86
103
  lastWidth = node.offsetWidth;
104
+ scrollHost = node;
105
+ node.addEventListener("scroll", rememberScroll, { capture: true, passive: true });
87
106
  if (wantsHeight()) node.style.height = `${lastHeight}px`;
88
107
  if (wantsWidth()) node.style.width = `${lastWidth}px`;
89
108
  raf = requestAnimationFrame(() => {
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { _ as _3 } from "./chunks/LpMapPicker-ul4oYno6.js";
4
4
  import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
5
5
  import { _ as _4 } from "./chunks/LpActionBar-BlvtGB2P.js";
6
6
  import { _ as _5 } from "./chunks/LpAlert-BAYHDSS1.js";
7
- import { _ as _6 } from "./chunks/LpAppShell-lVvpENMH.js";
7
+ import { _ as _6 } from "./chunks/LpAppShell-Cs9jDoqJ.js";
8
8
  import { _ as _7 } from "./chunks/LpAutocomplete-DExlIZe_.js";
9
9
  import { _ as _8 } from "./chunks/LpAvatar-D3l4nabg.js";
10
10
  import { _ as _9 } from "./chunks/LpBadge-Fe0D1bYx.js";
@@ -21,10 +21,10 @@ import { DEFAULT_SWATCHES, clampHsv, formatColor, hsvToHsl, hsvToRgb, isLight, n
21
21
  import { default as default3 } from "./components/LpCommandPalette.vue.js";
22
22
  import { _ as _17 } from "./chunks/LpConfirmDialog-B6RAvDES.js";
23
23
  import { _ as _18 } from "./chunks/LpContextMenu-B6WBLleS.js";
24
- import { _ as _19 } from "./chunks/LpDatePicker-6JFXllj5.js";
24
+ import { _ as _19 } from "./chunks/LpDatePicker-BPbvu3cZ.js";
25
25
  import { _ as _20 } from "./chunks/LpDisclosure-ChQYmjlR.js";
26
26
  import { _ as _21 } from "./chunks/LpDivider-CUX46KR0.js";
27
- import { _ as _22 } from "./chunks/LpDrawer-DAJN5rGK.js";
27
+ import { _ as _22 } from "./chunks/LpDrawer-CslZtgUm.js";
28
28
  import { _ as _23 } from "./chunks/LpDropdownMenu-DJpoqRCb.js";
29
29
  import { default as default4 } from "./components/LpDropzone.vue.js";
30
30
  import { _ as _24 } from "./chunks/LpEmptyState-Dsdqz1Wj.js";
@@ -41,7 +41,7 @@ import { _ as _31 } from "./chunks/LpLink-DCND-zyu.js";
41
41
  import { _ as _32 } from "./chunks/LpLogViewer-y53ZqSVq.js";
42
42
  import { _ as _33 } from "./chunks/LpMediaGallery-Cid7PGYx.js";
43
43
  import { _ as _34 } from "./chunks/LpModal-D3QxgWaT.js";
44
- import { _ as _35 } from "./chunks/LpModalNav-K6N5YpQk.js";
44
+ import { _ as _35 } from "./chunks/LpModalNav-Dkogc3O_.js";
45
45
  import { _ as _36 } from "./chunks/LpNotificationBell-lHePf89W.js";
46
46
  import { _ as _37 } from "./chunks/LpNumberField-CWuX08iX.js";
47
47
  import { _ as _38 } from "./chunks/LpNumberFlow-amWYaeuA.js";
@@ -63,7 +63,7 @@ import { _ as _52 } from "./chunks/LpSelect-ANIVtrpM.js";
63
63
  import { _ as _53 } from "./chunks/LpServiceNode-D8uJ8sGk.js";
64
64
  import { _ as _54 } from "./chunks/LpShift-DXNsauQZ.js";
65
65
  import { default as default6 } from "./components/LpShortcutsDialog.vue.js";
66
- import { _ as _55 } from "./chunks/LpSidebar-DcYavim2.js";
66
+ import { _ as _55 } from "./chunks/LpSidebar-DvGfofv6.js";
67
67
  import { _ as _56 } from "./chunks/LpSkeleton-CTI-vBgW.js";
68
68
  import { _ as _57 } from "./chunks/LpSlider-BwNIBkAK.js";
69
69
  import { _ as _58 } from "./chunks/LpSparkline-C6xQ2dwv.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -4,6 +4,14 @@
4
4
  * v-model), same as LpCalendar (which it embeds). The trigger shows a formatted
5
5
  * date; picking a day fills the field and closes the popover. Clearable, with
6
6
  * min/max + isDisabled forwarded to the calendar. Themed like the other inputs.
7
+ *
8
+ * With `time`, the model carries a minute too ("YYYY-MM-DDTHH:mm") and the
9
+ * popover grows a time row under the calendar. Anything that needs a moment
10
+ * rather than a day — an expiry, a scheduled start — would otherwise fall back
11
+ * to a native `datetime-local`, which the browser paints in its own locale and
12
+ * chrome, breaking the form it sits in. Picking a day keeps the time already
13
+ * chosen (default `defaultTime`), so the popover closes on the first click for
14
+ * the common case.
7
15
  */
8
16
  import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from "reka-ui"
9
17
  import { computed, ref } from "vue"
@@ -25,11 +33,19 @@ const props = withDefaults(
25
33
  size?: "sm" | "md" | "lg"
26
34
  /** Intl format for the displayed date. */
27
35
  format?: Intl.DateTimeFormatOptions
36
+ /** Also pick a minute; the model becomes "YYYY-MM-DDTHH:mm". */
37
+ time?: boolean
38
+ /** Time a freshly picked day starts at, "HH:mm". */
39
+ defaultTime?: string
40
+ /** Label of the button that closes the popover in `time` mode. */
41
+ doneLabel?: string
28
42
  }>(),
29
43
  {
30
44
  placeholder: "Pick a date",
31
45
  size: "md",
32
46
  format: () => ({ year: "numeric", month: "short", day: "numeric" }),
47
+ defaultTime: "00:00",
48
+ doneLabel: "Done",
33
49
  },
34
50
  )
35
51
 
@@ -39,12 +55,22 @@ const emit = defineEmits<{
39
55
 
40
56
  const open = ref(false)
41
57
 
58
+ /** Day half of the model; the calendar never sees the time. */
59
+ const day = computed(() => (props.modelValue || "").slice(0, 10))
60
+
61
+ /** Minute half, "HH:mm" — empty when the model carries a date only. */
62
+ const minute = computed(() => {
63
+ const at = (props.modelValue || "").slice(11, 16)
64
+ return /^\d{2}:\d{2}$/.test(at) ? at : ""
65
+ })
66
+
42
67
  const display = computed(() => {
43
68
  if (!props.modelValue) return ""
44
69
  // Parse as local midnight so the formatted day can't drift across a timezone.
45
- const [y, m, d] = props.modelValue.split("-").map(Number)
70
+ const [y, m, d] = day.value.split("-").map(Number)
46
71
  if (!y || !m || !d) return props.modelValue
47
- return new Intl.DateTimeFormat(undefined, props.format).format(new Date(y, m - 1, d))
72
+ const shown = new Intl.DateTimeFormat(undefined, props.format).format(new Date(y, m - 1, d))
73
+ return props.time && minute.value ? `${shown}, ${minute.value}` : shown
48
74
  })
49
75
 
50
76
  const shellSize = {
@@ -54,8 +80,21 @@ const shellSize = {
54
80
  }
55
81
 
56
82
  function onPick(v: string | undefined) {
57
- emit("update:modelValue", v)
58
- open.value = false
83
+ if (!props.time) {
84
+ emit("update:modelValue", v)
85
+ open.value = false
86
+ return
87
+ }
88
+ // The popover stays open: the time row below is part of the same choice, and
89
+ // closing on the day would hide it right as it becomes reachable.
90
+ emit("update:modelValue", v ? `${v}T${minute.value || props.defaultTime}` : undefined)
91
+ }
92
+
93
+ function onTime(at: string) {
94
+ // A time without a day would be a model no consumer can use; until a day is
95
+ // picked the row is disabled, so this only guards a stray event.
96
+ if (!day.value) return
97
+ emit("update:modelValue", `${day.value}T${at || props.defaultTime}`)
59
98
  }
60
99
 
61
100
  function clear() {
@@ -104,13 +143,30 @@ function clear() {
104
143
  :class="[POPOVER_PANEL, 'z-(--z-popover) rounded-card p-0 outline-none']"
105
144
  >
106
145
  <LpCalendar
107
- :model-value="modelValue"
146
+ :model-value="day"
108
147
  :min="min"
109
148
  :max="max"
110
149
  :is-disabled="isDisabled"
111
150
  class="border-0 bg-transparent"
112
151
  @update:model-value="onPick"
113
152
  />
153
+ <div v-if="time" class="flex items-center gap-2 border-t border-line px-3 py-2">
154
+ <LpIcon name="lucide:clock" :size="15" class="shrink-0 text-muted" />
155
+ <input
156
+ type="time"
157
+ :value="minute"
158
+ :disabled="!day"
159
+ class="h-(--size-control-sm) flex-1 rounded-control border border-line bg-surface-soft px-2 text-sm text-ink outline-none transition-colors focus-visible:border-brand focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-55"
160
+ @input="onTime(($event.target as HTMLInputElement).value)"
161
+ />
162
+ <button
163
+ type="button"
164
+ class="shrink-0 rounded-control px-2 py-1 text-xs text-muted transition-colors hover:text-ink"
165
+ @click="open = false"
166
+ >
167
+ {{ doneLabel }}
168
+ </button>
169
+ </div>
114
170
  </PopoverContent>
115
171
  </PopoverPortal>
116
172
  </PopoverRoot>
@@ -23,10 +23,11 @@ import {
23
23
  DrawerRoot,
24
24
  DrawerTitle,
25
25
  } from "vaul-vue"
26
- import { computed, ref, useSlots } from "vue"
26
+ import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
27
27
  import { CLOSE_ICON } from "./dropdown"
28
28
  import LpIcon from "./LpIcon.vue"
29
29
  import LpScrollArea from "./LpScrollArea.vue"
30
+ import { useModalLayer } from "./modalDepth"
30
31
 
31
32
  type Direction = "top" | "bottom" | "left" | "right"
32
33
 
@@ -125,6 +126,16 @@ const SIZES = { sm: 22, md: 28, lg: 36, xl: 48 } // rem along the main axis
125
126
  // Geometry per edge. The cross-axis is pinned to the viewport; the main axis
126
127
  // takes the size preset. vaul drives the open/close transform itself, so we
127
128
  // don't add slide keyframes — only the resting box + rounding toward the centre.
129
+ /*
130
+ * The drawer joins the same stack as LpModal. It painted at the fixed
131
+ * --z-overlay/--z-modal rungs, so a dialog opened from it (a picker inside a
132
+ * settings drawer) claimed those same rungs and the tie went to DOM order —
133
+ * the drawer, portalled later on reopen, covered the dialog it had opened.
134
+ */
135
+ const { claim, release, layer } = useModalLayer()
136
+ watch(() => props.open, (open) => (open ? claim() : release()), { immediate: true })
137
+ onBeforeUnmount(release)
138
+
128
139
  const contentClass = computed(() => {
129
140
  const base = "fixed z-(--z-modal) flex flex-col border-line bg-surface-raised shadow-panel outline-none"
130
141
  const map: Record<Direction, string> = {
@@ -345,11 +356,12 @@ const edgeStripClass = computed(() =>
345
356
  >
346
357
  <DrawerPortal>
347
358
  <DrawerOverlay
359
+ :style="{ zIndex: layer.scrim }"
348
360
  class="lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
349
361
  />
350
362
  <DrawerContent
351
363
  :class="contentClass"
352
- :style="sizeStyle"
364
+ :style="[sizeStyle, { zIndex: layer.panel }]"
353
365
  :data-vaul-animate="handingOff ? 'false' : undefined"
354
366
  >
355
367
  <!-- Resize grip on the inner edge (horizontal drawers). data-vaul-no-drag
@@ -26,8 +26,15 @@ import { computed, ref, type ComputedRef } from "vue"
26
26
  * is 100 and --z-modal 110, so a gap of 2 left the upper scrim under the lower
27
27
  * PANEL — it darkened that panel and could not blur it, a backdrop-filter
28
28
  * having nothing of it underneath to work on.
29
+ *
30
+ * Clearing it means strictly more than the 10 between the two rungs. A step of
31
+ * exactly 10 put the lower panel at 100, level with the upper scrim, and a tie
32
+ * in z-index is settled by DOM order. Siblings in one component happened to
33
+ * come out right, but a dialog mounted once at the app root — opened from a
34
+ * page dialog that teleported in later — lost the tie, and its scrim darkened
35
+ * the page behind the first dialog instead of the first dialog itself.
29
36
  */
30
- const STEP = 10
37
+ const STEP = 20
31
38
 
32
39
  /** Levels currently claimed, oldest first. */
33
40
  const stack = ref<number[]>([])
@@ -91,6 +91,25 @@ export function useShift(
91
91
  let inFlight = false
92
92
  /** When the in-flight tween started, to tell a duplicate from a follow-up. */
93
93
  let tweenStart = 0
94
+ /**
95
+ * Scroll positions inside the box, kept from the scroll events themselves.
96
+ * Measuring lifts the height cap, and for that one reflow a scrolling list
97
+ * inside (a picker, a span tree) is as tall as its content: it no longer
98
+ * overflows, the browser clamps its scrollTop to 0, and putting the cap back
99
+ * does not bring the position back. Every click that changed the content —
100
+ * ticking an item, picking a span — threw the list back to the top.
101
+ */
102
+ const scrolled = new Map<Element, number>()
103
+ const rememberScroll = (event: Event) => {
104
+ const target = event.target
105
+ if (target instanceof Element) scrolled.set(target, target.scrollTop)
106
+ }
107
+ function restoreScroll() {
108
+ for (const [node, top] of scrolled) {
109
+ if (!node.isConnected) scrolled.delete(node)
110
+ else if (node.scrollTop !== top) node.scrollTop = top
111
+ }
112
+ }
94
113
 
95
114
  const axisOf = () => toValue(options.axis) ?? "height"
96
115
  const wantsHeight = () => axisOf() !== "width"
@@ -151,6 +170,7 @@ export function useShift(
151
170
  const toWidth = wantsWidth() ? node.offsetWidth : 0
152
171
  if (wantsHeight()) node.style.maxHeight = ""
153
172
  if (wantsWidth()) node.style.maxWidth = ""
173
+ restoreScroll()
154
174
  lastHeight = toHeight
155
175
  lastWidth = toWidth
156
176
 
@@ -173,6 +193,7 @@ export function useShift(
173
193
  void node.offsetHeight
174
194
  if (movesHeight) node.style.height = `${toHeight}px`
175
195
  if (movesWidth) node.style.width = `${toWidth}px`
196
+ restoreScroll()
176
197
 
177
198
  inFlight = true
178
199
  tweenStart = performance.now()
@@ -194,7 +215,12 @@ export function useShift(
194
215
  }, wait)
195
216
  }
196
217
 
218
+ let scrollHost: HTMLElement | null = null
219
+
197
220
  function teardown() {
221
+ scrollHost?.removeEventListener("scroll", rememberScroll, true)
222
+ scrollHost = null
223
+ scrolled.clear()
198
224
  ro?.disconnect()
199
225
  mo?.disconnect()
200
226
  ro = undefined
@@ -218,6 +244,8 @@ export function useShift(
218
244
 
219
245
  lastHeight = node.offsetHeight
220
246
  lastWidth = node.offsetWidth
247
+ scrollHost = node
248
+ node.addEventListener("scroll", rememberScroll, { capture: true, passive: true })
221
249
  if (wantsHeight()) node.style.height = `${lastHeight}px`
222
250
  if (wantsWidth()) node.style.width = `${lastWidth}px`
223
251
  // A box must not animate in from its pre-content size, so the transition