@leavepulse/ui 0.12.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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/component-names.d.ts +1 -0
  4. package/dist/components/LayoutCanvas.vue.d.ts +28 -0
  5. package/dist/components/LayoutNode.vue.d.ts +40 -0
  6. package/dist/components/LpAlert.vue.d.ts +44 -0
  7. package/dist/components/LpAppShell.vue.d.ts +83 -0
  8. package/dist/components/LpAutocomplete.vue.d.ts +50 -0
  9. package/dist/components/LpAvatar.vue.d.ts +20 -0
  10. package/dist/components/LpBadge.vue.d.ts +47 -0
  11. package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
  12. package/dist/components/LpButton.vue.d.ts +113 -0
  13. package/dist/components/LpCalendar.vue.d.ts +20 -0
  14. package/dist/components/LpCard.vue.d.ts +29 -0
  15. package/dist/components/LpCheckbox.vue.d.ts +28 -0
  16. package/dist/components/LpCodeBlock.vue.d.ts +39 -0
  17. package/dist/components/LpCommandPalette.vue.d.ts +42 -0
  18. package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
  19. package/dist/components/LpContextMenu.vue.d.ts +30 -0
  20. package/dist/components/LpDatePicker.vue.d.ts +25 -0
  21. package/dist/components/LpDisclosure.vue.d.ts +37 -0
  22. package/dist/components/LpDivider.vue.d.ts +17 -0
  23. package/dist/components/LpDrawer.vue.d.ts +88 -0
  24. package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
  25. package/dist/components/LpEmptyState.vue.d.ts +18 -0
  26. package/dist/components/LpFormField.vue.d.ts +21 -0
  27. package/dist/components/LpIcon.vue.d.ts +8 -0
  28. package/dist/components/LpInfraNode.vue.d.ts +21 -0
  29. package/dist/components/LpInput.vue.d.ts +68 -0
  30. package/dist/components/LpLaneNode.vue.d.ts +24 -0
  31. package/dist/components/LpLink.vue.d.ts +27 -0
  32. package/dist/components/LpLogViewer.vue.d.ts +92 -0
  33. package/dist/components/LpModal.vue.d.ts +43 -0
  34. package/dist/components/LpNotificationBell.vue.d.ts +51 -0
  35. package/dist/components/LpNumberField.vue.d.ts +14 -0
  36. package/dist/components/LpOtpInput.vue.d.ts +27 -0
  37. package/dist/components/LpPagination.vue.d.ts +25 -0
  38. package/dist/components/LpPasswordInput.vue.d.ts +28 -0
  39. package/dist/components/LpPhoneInput.vue.d.ts +37 -0
  40. package/dist/components/LpPopover.vue.d.ts +34 -0
  41. package/dist/components/LpProgress.vue.d.ts +12 -0
  42. package/dist/components/LpRadio.vue.d.ts +18 -0
  43. package/dist/components/LpRadioGroup.vue.d.ts +26 -0
  44. package/dist/components/LpScrollArea.vue.d.ts +24 -0
  45. package/dist/components/LpSegmented.vue.d.ts +24 -0
  46. package/dist/components/LpSelect.vue.d.ts +45 -0
  47. package/dist/components/LpServiceNode.vue.d.ts +14 -0
  48. package/dist/components/LpSidebar.vue.d.ts +85 -0
  49. package/dist/components/LpSidebarNav.vue.d.ts +34 -0
  50. package/dist/components/LpSkeleton.vue.d.ts +6 -0
  51. package/dist/components/LpSlider.vue.d.ts +23 -0
  52. package/dist/components/LpStat.vue.d.ts +22 -0
  53. package/dist/components/LpStepper.vue.d.ts +10 -0
  54. package/dist/components/LpSwitch.vue.d.ts +11 -0
  55. package/dist/components/LpTable.vue.d.ts +63 -0
  56. package/dist/components/LpTableOfContents.vue.d.ts +19 -0
  57. package/dist/components/LpTabs.vue.d.ts +45 -0
  58. package/dist/components/LpTextarea.vue.d.ts +17 -0
  59. package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
  60. package/dist/components/LpToaster.vue.d.ts +3 -0
  61. package/dist/components/LpTooltip.vue.d.ts +19 -0
  62. package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
  63. package/dist/components/LpUptimeBar.vue.d.ts +30 -0
  64. package/dist/components/codeHighlight.d.ts +8 -0
  65. package/dist/components/countries-data.d.ts +2 -0
  66. package/dist/components/countries.d.ts +20 -0
  67. package/dist/components/dropdown.d.ts +29 -0
  68. package/dist/components/sidebar.d.ts +18 -0
  69. package/dist/composables/useInputFilter.d.ts +14 -0
  70. package/dist/composables/usePillTransition.d.ts +9 -0
  71. package/dist/composables/useToast.d.ts +39 -0
  72. package/dist/countries-data-9rgqJWHo.js +203 -0
  73. package/dist/icons.d.ts +1 -0
  74. package/dist/index.css +1 -0
  75. package/dist/index.d.ts +100 -0
  76. package/dist/index.js +17600 -0
  77. package/dist/layout/registry.d.ts +26 -0
  78. package/dist/layout/tree.d.ts +75 -0
  79. package/dist/layout/useLayout.d.ts +39 -0
  80. package/dist/theme/config.d.ts +16 -0
  81. package/dist/theme/define.d.ts +12 -0
  82. package/dist/theme/presets.d.ts +20 -0
  83. package/dist/theme/tokens.d.ts +72 -0
  84. package/dist/theme/useTheme.d.ts +36 -0
  85. package/package.json +93 -0
  86. package/src/canvas.css +67 -0
  87. package/src/component-names.ts +65 -0
  88. package/src/components/LayoutCanvas.vue +76 -0
  89. package/src/components/LayoutNode.vue +248 -0
  90. package/src/components/LpAlert.vue +59 -0
  91. package/src/components/LpAppShell.vue +210 -0
  92. package/src/components/LpAutocomplete.vue +244 -0
  93. package/src/components/LpAvatar.vue +67 -0
  94. package/src/components/LpBadge.vue +32 -0
  95. package/src/components/LpBreadcrumbs.vue +35 -0
  96. package/src/components/LpButton.vue +111 -0
  97. package/src/components/LpCalendar.vue +134 -0
  98. package/src/components/LpCard.vue +71 -0
  99. package/src/components/LpCheckbox.vue +44 -0
  100. package/src/components/LpCodeBlock.vue +298 -0
  101. package/src/components/LpCommandPalette.vue +323 -0
  102. package/src/components/LpConfirmDialog.vue +49 -0
  103. package/src/components/LpContextMenu.vue +111 -0
  104. package/src/components/LpDatePicker.vue +117 -0
  105. package/src/components/LpDisclosure.vue +92 -0
  106. package/src/components/LpDivider.vue +20 -0
  107. package/src/components/LpDrawer.vue +402 -0
  108. package/src/components/LpDropdownMenu.vue +54 -0
  109. package/src/components/LpEmptyState.vue +21 -0
  110. package/src/components/LpFormField.vue +31 -0
  111. package/src/components/LpIcon.vue +49 -0
  112. package/src/components/LpInfraNode.vue +108 -0
  113. package/src/components/LpInput.vue +147 -0
  114. package/src/components/LpLaneNode.vue +48 -0
  115. package/src/components/LpLink.vue +48 -0
  116. package/src/components/LpLogViewer.vue +569 -0
  117. package/src/components/LpModal.vue +123 -0
  118. package/src/components/LpNotificationBell.vue +238 -0
  119. package/src/components/LpNumberField.vue +56 -0
  120. package/src/components/LpOtpInput.vue +81 -0
  121. package/src/components/LpPagination.vue +165 -0
  122. package/src/components/LpPasswordInput.vue +50 -0
  123. package/src/components/LpPhoneInput.vue +241 -0
  124. package/src/components/LpPopover.vue +42 -0
  125. package/src/components/LpProgress.vue +30 -0
  126. package/src/components/LpRadio.vue +28 -0
  127. package/src/components/LpRadioGroup.vue +43 -0
  128. package/src/components/LpScrollArea.vue +82 -0
  129. package/src/components/LpSegmented.vue +83 -0
  130. package/src/components/LpSelect.vue +0 -0
  131. package/src/components/LpServiceNode.vue +57 -0
  132. package/src/components/LpSidebar.vue +200 -0
  133. package/src/components/LpSidebarNav.vue +118 -0
  134. package/src/components/LpSkeleton.vue +16 -0
  135. package/src/components/LpSlider.vue +53 -0
  136. package/src/components/LpStat.vue +90 -0
  137. package/src/components/LpStepper.vue +46 -0
  138. package/src/components/LpSwitch.vue +19 -0
  139. package/src/components/LpTable.vue +252 -0
  140. package/src/components/LpTableOfContents.vue +183 -0
  141. package/src/components/LpTabs.vue +120 -0
  142. package/src/components/LpTextarea.vue +39 -0
  143. package/src/components/LpThemeSwitcher.vue +168 -0
  144. package/src/components/LpToaster.vue +216 -0
  145. package/src/components/LpTooltip.vue +30 -0
  146. package/src/components/LpTopologyCanvas.vue +370 -0
  147. package/src/components/LpUptimeBar.vue +126 -0
  148. package/src/components/codeHighlight.ts +137 -0
  149. package/src/components/countries-data.ts +207 -0
  150. package/src/components/countries.ts +67 -0
  151. package/src/components/dropdown.ts +64 -0
  152. package/src/components/sidebar.ts +26 -0
  153. package/src/composables/useInputFilter.ts +89 -0
  154. package/src/composables/usePillTransition.ts +19 -0
  155. package/src/composables/useToast.ts +92 -0
  156. package/src/icons.ts +11 -0
  157. package/src/index.css +21 -0
  158. package/src/index.ts +146 -0
  159. package/src/layout/registry.ts +44 -0
  160. package/src/layout/tree.ts +288 -0
  161. package/src/layout/useLayout.ts +134 -0
  162. package/src/nuxt.ts +22 -0
  163. package/src/theme/config.ts +48 -0
  164. package/src/theme/define.ts +50 -0
  165. package/src/theme/presets.ts +279 -0
  166. package/src/theme/tokens.ts +151 -0
  167. package/src/theme/useTheme.ts +288 -0
  168. package/src/tokens/scrollbar.css +138 -0
  169. package/src/tokens/tokens.css +333 -0
  170. package/src/tokens/utilities.css +23 -0
@@ -0,0 +1,71 @@
1
+ <script lang="ts">
2
+ // Opt out of auto attr inheritance: the template has two root branches (menu /
3
+ // no-menu), so fallthrough class/attrs/events are bound explicitly onto the
4
+ // inner <div> via v-bind="$attrs" in both — keeping them on the card surface.
5
+ export default { inheritAttrs: false }
6
+ </script>
7
+
8
+ <script setup lang="ts">
9
+ import { tv, type VariantProps } from "tailwind-variants"
10
+ import { computed } from "vue"
11
+ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
12
+
13
+ const card = tv({
14
+ base: "rounded-card border bg-surface-raised",
15
+ variants: {
16
+ variant: {
17
+ // raised opts into the skin painter (.lp-skin-panel) so it follows the
18
+ // active surface tokens (flat → glass) instead of a fixed shadow.
19
+ raised: "lp-skin-panel border-line",
20
+ flat: "border-line",
21
+ ghost: "border-transparent bg-transparent",
22
+ },
23
+ padded: { true: "p-5", false: "" },
24
+ // Lift on hover with a BRAND-tinted glow + brand border (a black drop-shadow
25
+ // was invisible on dark themes — it only showed on light). We write the glow
26
+ // through `--surface-panel-shadow`, the very variable the skin-painter feeds
27
+ // into .lp-skin-panel's box-shadow, AND set box-shadow directly — so the glow
28
+ // lands on both the `raised` variant (skin panel, whose own box-shadow would
29
+ // otherwise win, being declared outside @layer) and `flat`/`ghost`.
30
+ // NOTE: in Tailwind v4 `translate-*` writes the native `translate` property
31
+ // (not `transform`), so the transition list MUST name `translate` explicitly
32
+ // or the lift snaps while the rest eases.
33
+ interactive: {
34
+ true: "cursor-pointer transition-[translate,box-shadow,border-color] duration-medium ease-[var(--ease-emphasized)] hover:-translate-y-0.5 hover:border-brand/40 hover:shadow-[var(--lp-card-glow)] hover:[--surface-panel-shadow:var(--lp-card-glow)] [--lp-card-glow:0_8px_24px_-6px_color-mix(in_srgb,var(--color-brand)_38%,transparent)] motion-reduce:transition-none motion-reduce:hover:translate-y-0",
35
+ },
36
+ },
37
+ })
38
+
39
+ type CardVariants = VariantProps<typeof card>
40
+
41
+ // Defaults live on the props, not on tv() — passing `undefined` into tv
42
+ // overrides its defaultVariants and silently drops the padding.
43
+ const props = withDefaults(
44
+ defineProps<{
45
+ variant?: CardVariants["variant"]
46
+ padded?: boolean
47
+ interactive?: boolean
48
+ /** Right-click menu for the card. Omit (or pass []) to keep the native one. */
49
+ menuItems?: ContextMenuItemDef[]
50
+ }>(),
51
+ { variant: "raised", padded: true, interactive: false },
52
+ )
53
+
54
+ const classes = computed(() =>
55
+ card({ variant: props.variant, padded: props.padded, interactive: props.interactive }),
56
+ )
57
+ </script>
58
+
59
+ <template>
60
+ <!-- Fallthrough class/attrs/events are bound onto the <div> in both branches
61
+ (inheritAttrs:false above), so the card surface looks identical whether or
62
+ not it's wrapped in a context menu. -->
63
+ <LpContextMenu v-if="menuItems?.length" :items="menuItems">
64
+ <div :class="classes" v-bind="$attrs">
65
+ <slot />
66
+ </div>
67
+ </LpContextMenu>
68
+ <div v-else :class="classes" v-bind="$attrs">
69
+ <slot />
70
+ </div>
71
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import { CheckboxIndicator, CheckboxRoot } from "reka-ui"
3
+ import LpIcon from "./LpIcon.vue"
4
+
5
+ defineProps<{
6
+ modelValue?: boolean
7
+ /** Mixed state (e.g. a "select all" with a partial selection). Shows a dash. */
8
+ indeterminate?: boolean
9
+ disabled?: boolean
10
+ label?: string
11
+ id?: string
12
+ name?: string
13
+ }>()
14
+ const emit = defineEmits<{
15
+ (e: "update:modelValue", value: boolean): void
16
+ (e: "change", value: boolean): void
17
+ }>()
18
+
19
+ function onUpdate(v: boolean | "indeterminate") {
20
+ const checked = v === true
21
+ emit("update:modelValue", checked)
22
+ emit("change", checked)
23
+ }
24
+ </script>
25
+
26
+ <template>
27
+ <label class="group inline-flex cursor-pointer items-center gap-2 text-sm text-ink">
28
+ <CheckboxRoot
29
+ :id="id"
30
+ :name="name"
31
+ :model-value="indeterminate ? 'indeterminate' : modelValue"
32
+ :disabled="disabled"
33
+ class="flex size-[18px] items-center justify-center rounded-[5px] border border-line-strong bg-surface-soft outline-none transition-[color,background-color,border-color,box-shadow,scale] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] not-data-[state=checked]:not-data-[state=indeterminate]:group-hover:border-brand active:scale-90 motion-reduce:active:scale-100 focus-visible:ring-2 focus-visible:ring-ring data-[state=checked]:border-transparent data-[state=checked]:bg-brand data-[state=indeterminate]:border-transparent data-[state=indeterminate]:bg-brand disabled:cursor-not-allowed disabled:opacity-55"
34
+ @update:model-value="onUpdate"
35
+ >
36
+ <CheckboxIndicator
37
+ class="flex items-center justify-center text-ink-inverse data-[state=checked]:animate-[indicator-in_140ms_var(--ease-emphasized)] data-[state=indeterminate]:animate-[indicator-in_140ms_var(--ease-emphasized)]"
38
+ >
39
+ <LpIcon :name="indeterminate ? 'lucide:minus' : 'lucide:check'" :size="13" />
40
+ </CheckboxIndicator>
41
+ </CheckboxRoot>
42
+ <slot>{{ label }}</slot>
43
+ </label>
44
+ </template>
@@ -0,0 +1,298 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Code block with lightweight, theme-aware syntax highlighting (codeHighlight.ts
4
+ * — zero deps, synchronous). Monospace, optional line numbers + wrapping, a copy
5
+ * button, and an optional language label.
6
+ *
7
+ * Lock: `locked` (default true) is read-only. Set `:locked="false"` to make it an
8
+ * editable code field — v-model writes back the text, highlighting stays in sync,
9
+ * and a header toggle flips lock/unlock. Useful for editable config snippets.
10
+ *
11
+ * Folding: wrap a range in `// #region label` … `// #endregion` markers (or `#`
12
+ * for shell/python/yaml/toml) to make it collapsible — handy for boilerplate that
13
+ * is useful but not essential to the point. The marker lines are removed from the
14
+ * output and from copied text. `foldImports` additionally collapses a leading
15
+ * import/package block automatically without markers. Regions collapse by default;
16
+ * set `region:open` (e.g. `// #region label open`) to start expanded.
17
+ */
18
+ import { computed, reactive, ref, watch } from "vue"
19
+ import { type CodeLang, tokenizeLine } from "./codeHighlight"
20
+ import LpIcon from "./LpIcon.vue"
21
+
22
+ const props = withDefaults(
23
+ defineProps<{
24
+ /** The code (v-model when editable). */
25
+ modelValue?: string
26
+ lang?: CodeLang
27
+ /** Show 1-based gutter line numbers. */
28
+ lineNumbers?: boolean
29
+ /** Wrap long lines instead of scrolling horizontally. */
30
+ wrap?: boolean
31
+ /** Read-only. Set false for an editable field with a lock toggle. */
32
+ locked?: boolean
33
+ /** Show the lock/unlock toggle in the header (opt-in editability). */
34
+ lockToggle?: boolean
35
+ /** Show the copy button. */
36
+ copyable?: boolean
37
+ /** Caption shown in the header (e.g. a filename). */
38
+ title?: string
39
+ /** Fixed height; defaults to content height. */
40
+ height?: string
41
+ /** Auto-collapse a leading import/package block (no markers needed). */
42
+ foldImports?: boolean
43
+ }>(),
44
+ {
45
+ lang: "plain",
46
+ locked: true,
47
+ lockToggle: false,
48
+ copyable: true,
49
+ lineNumbers: false,
50
+ wrap: false,
51
+ foldImports: false,
52
+ },
53
+ )
54
+
55
+ const emit = defineEmits<{
56
+ (e: "update:modelValue", value: string): void
57
+ (e: "update:locked", value: boolean): void
58
+ }>()
59
+
60
+ const code = computed(() => props.modelValue ?? "")
61
+ const rawLines = computed(() => code.value.replace(/\n$/, "").split("\n"))
62
+
63
+ // ── fold parsing ─────────────────────────────────────────────
64
+ // A segment is either a run of plain lines or a collapsible region. Line objects
65
+ // carry their original 1-based number so the gutter stays truthful after markers
66
+ // are stripped.
67
+ type NumberedLine = { n: number; text: string }
68
+ type Segment =
69
+ | { kind: "lines"; lines: NumberedLine[] }
70
+ | { kind: "fold"; id: number; label: string; defaultOpen: boolean; lines: NumberedLine[] }
71
+
72
+ const REGION_START = /^\s*(?:\/\/|#|--)\s*#region\b\s*(.*?)\s*$/
73
+ const REGION_END = /^\s*(?:\/\/|#|--)\s*#endregion\b/
74
+ const IMPORT_LINE = /^\s*(?:import|package|use|from|#include|using)\b/
75
+
76
+ /** Split raw lines into plain/fold segments, dropping the marker lines. */
77
+ const segments = computed<Segment[]>(() => {
78
+ const out: Segment[] = []
79
+ const src = rawLines.value
80
+ let plain: NumberedLine[] = []
81
+ let foldId = 0
82
+
83
+ const flushPlain = () => {
84
+ if (plain.length) {
85
+ out.push({ kind: "lines", lines: plain })
86
+ plain = []
87
+ }
88
+ }
89
+
90
+ // Optional automatic import fold: a leading block of import/package lines that
91
+ // isn't already inside an explicit region.
92
+ let i = 0
93
+ if (props.foldImports && !REGION_START.test(src[0] ?? "")) {
94
+ // Skip a leading blank line or two (rare), then collect the import run.
95
+ let start = 0
96
+ while (start < src.length && src[start].trim() === "") start++
97
+ let end = start
98
+ while (end < src.length && (IMPORT_LINE.test(src[end]) || src[end].trim() === "")) end++
99
+ // Trim trailing blank lines back out of the region.
100
+ let realEnd = end
101
+ while (realEnd > start && src[realEnd - 1].trim() === "") realEnd--
102
+ if (realEnd - start >= 1) {
103
+ // Keep any lines before `start` as plain (e.g. a leading blank).
104
+ for (let k = 0; k < start; k++) plain.push({ n: k + 1, text: src[k] })
105
+ flushPlain()
106
+ const lines: NumberedLine[] = []
107
+ for (let k = start; k < realEnd; k++) lines.push({ n: k + 1, text: src[k] })
108
+ out.push({ kind: "fold", id: foldId++, label: "imports", defaultOpen: false, lines })
109
+ i = realEnd
110
+ }
111
+ }
112
+
113
+ for (; i < src.length; i++) {
114
+ const line = src[i]
115
+ const startMatch = line.match(REGION_START)
116
+ if (startMatch) {
117
+ flushPlain()
118
+ const rest = startMatch[1] ?? ""
119
+ const defaultOpen = /\bopen\b/.test(rest)
120
+ const label = rest.replace(/\bopen\b/, "").trim() || "details"
121
+ const lines: NumberedLine[] = []
122
+ i++
123
+ for (; i < src.length; i++) {
124
+ if (REGION_END.test(src[i])) break
125
+ lines.push({ n: i + 1, text: src[i] })
126
+ }
127
+ out.push({ kind: "fold", id: foldId++, label, defaultOpen, lines })
128
+ continue
129
+ }
130
+ plain.push({ n: i + 1, text: line })
131
+ }
132
+ flushPlain()
133
+ return out
134
+ })
135
+
136
+ const hasFolds = computed(() => segments.value.some((s) => s.kind === "fold"))
137
+
138
+ // Per-region open state, keyed by fold id, defaulting to each region's declared
139
+ // open flag. Reset when the set of folds changes.
140
+ const openState = reactive<Record<number, boolean>>({})
141
+ watch(
142
+ segments,
143
+ (segs) => {
144
+ for (const key of Object.keys(openState)) delete openState[Number(key)]
145
+ for (const s of segs) if (s.kind === "fold") openState[s.id] = s.defaultOpen
146
+ },
147
+ { immediate: true },
148
+ )
149
+ function toggleFold(id: number) {
150
+ openState[id] = !openState[id]
151
+ }
152
+
153
+ /** Highlight one line's text into tokens. */
154
+ function tokens(text: string) {
155
+ return tokenizeLine(text, props.lang)
156
+ }
157
+
158
+ // Copied text is the real code with marker lines removed (so it pastes clean),
159
+ // regardless of what's currently collapsed.
160
+ const copyText = computed(() =>
161
+ segments.value
162
+ .flatMap((s) => s.lines.map((l) => l.text))
163
+ .join("\n"),
164
+ )
165
+
166
+ const showHeader = computed(
167
+ () => Boolean(props.title || props.lang !== "plain" || props.copyable || props.lockToggle),
168
+ )
169
+
170
+ // ── copy ─────────────────────────────────────────────────────
171
+ const copied = ref(false)
172
+ async function copy() {
173
+ try {
174
+ await navigator.clipboard.writeText(copyText.value)
175
+ copied.value = true
176
+ window.setTimeout(() => (copied.value = false), 1400)
177
+ } catch {
178
+ /* clipboard blocked — no-op */
179
+ }
180
+ }
181
+
182
+ function toggleLock() {
183
+ emit("update:locked", !props.locked)
184
+ }
185
+
186
+ function onInput(e: Event) {
187
+ emit("update:modelValue", (e.target as HTMLTextAreaElement).value)
188
+ }
189
+ </script>
190
+
191
+ <template>
192
+ <div class="overflow-hidden rounded-card border border-line bg-surface font-mono text-xs">
193
+ <!-- Header -->
194
+ <div
195
+ v-if="showHeader"
196
+ class="flex items-center gap-2 border-b border-line bg-surface-soft px-3 py-1.5 text-muted"
197
+ >
198
+ <span v-if="title" class="truncate text-ink">{{ title }}</span>
199
+ <span v-if="lang !== 'plain'" class="rounded bg-surface px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
200
+ {{ lang }}
201
+ </span>
202
+ <div class="ml-auto flex items-center gap-1">
203
+ <button
204
+ v-if="lockToggle"
205
+ type="button"
206
+ class="group/lock flex items-center gap-1 rounded px-1.5 py-1 outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
207
+ :aria-label="locked ? 'Unlock to edit' : 'Lock (read-only)'"
208
+ @click="toggleLock"
209
+ >
210
+ <LpIcon
211
+ :name="locked ? 'lucide:lock' : 'lucide:lock-open'"
212
+ :size="13"
213
+ class="transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-active/lock:scale-90"
214
+ />
215
+ </button>
216
+ <button
217
+ v-if="copyable"
218
+ type="button"
219
+ class="group/copy flex items-center gap-1 rounded px-1.5 py-1 outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
220
+ :aria-label="copied ? 'Copied' : 'Copy code'"
221
+ @click="copy"
222
+ >
223
+ <LpIcon
224
+ :name="copied ? 'lucide:check' : 'lucide:copy'"
225
+ :size="13"
226
+ :class="copied ? 'text-action' : ''"
227
+ />
228
+ </button>
229
+ </div>
230
+ </div>
231
+
232
+ <!-- Body. When there are no folds this stays a plain overlay editor; with
233
+ folds we render segments (the textarea editor is folding-agnostic and is
234
+ only used when unlocked, which authored docs never combine with folds). -->
235
+ <div
236
+ class="lp-scrollbar-none relative"
237
+ :style="height ? { height } : undefined"
238
+ :class="height ? 'overflow-auto' : ''"
239
+ >
240
+ <!-- Editable overlay: only when unlocked and there are no folds. -->
241
+ <textarea
242
+ v-if="!locked && !hasFolds"
243
+ :value="code"
244
+ spellcheck="false"
245
+ class="lp-scrollbar-none absolute inset-0 size-full resize-none overflow-auto whitespace-pre bg-transparent p-3 leading-relaxed text-transparent caret-brand outline-none"
246
+ :class="[wrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre', lineNumbers ? 'pl-12' : '']"
247
+ @input="onInput"
248
+ />
249
+ <pre
250
+ class="lp-scrollbar-none m-0 overflow-auto p-3 leading-relaxed"
251
+ :class="[wrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre', !locked && !hasFolds ? 'pointer-events-none' : '']"
252
+ ><code class="block"><template
253
+ v-for="(seg, s) in segments"
254
+ :key="s"
255
+ ><!-- Plain lines --><template v-if="seg.kind === 'lines'"><span
256
+ v-for="ln in seg.lines"
257
+ :key="'l' + ln.n"
258
+ class="block min-h-[1.4em]"
259
+ ><span
260
+ v-if="lineNumbers"
261
+ class="mr-3 inline-block w-7 select-none text-right text-muted/60"
262
+ aria-hidden="true"
263
+ >{{ ln.n }}</span><span
264
+ v-for="(tok, j) in tokens(ln.text)"
265
+ :key="j"
266
+ :class="tok.cls"
267
+ >{{ tok.text }}</span></span></template><!-- Fold region --><template
268
+ v-else
269
+ ><button
270
+ type="button"
271
+ class="lp-fold-toggle flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
272
+ :aria-expanded="openState[seg.id]"
273
+ @click="toggleFold(seg.id)"
274
+ ><LpIcon
275
+ :name="openState[seg.id] ? 'lucide:chevron-down' : 'lucide:chevron-right'"
276
+ :size="12"
277
+ class="shrink-0"
278
+ /><span class="select-none">{{ openState[seg.id] ? seg.label : seg.label + ' …' }}</span><span
279
+ v-if="!openState[seg.id]"
280
+ class="select-none text-muted/50"
281
+ >({{ seg.lines.length }} lines)</span></button><template
282
+ v-if="openState[seg.id]"
283
+ ><span
284
+ v-for="ln in seg.lines"
285
+ :key="'f' + ln.n"
286
+ class="block min-h-[1.4em]"
287
+ ><span
288
+ v-if="lineNumbers"
289
+ class="mr-3 inline-block w-7 select-none text-right text-muted/60"
290
+ aria-hidden="true"
291
+ >{{ ln.n }}</span><span
292
+ v-for="(tok, j) in tokens(ln.text)"
293
+ :key="j"
294
+ :class="tok.cls"
295
+ >{{ tok.text }}</span></span></template></template></template></code></pre>
296
+ </div>
297
+ </div>
298
+ </template>