@proj-airi/ui 0.9.0 → 0.10.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": "@proj-airi/ui",
3
3
  "type": "module",
4
- "version": "0.9.0",
4
+ "version": "0.10.0",
5
5
  "description": "A collection of UI components that used by Project AIRI",
6
6
  "author": {
7
7
  "name": "Moeru AI Project AIRI Team",
@@ -26,8 +26,8 @@
26
26
  "@moeru/std": "0.1.0-beta.17",
27
27
  "@vueuse/core": "^14.2.1",
28
28
  "floating-vue": "^5.2.2",
29
- "reka-ui": "^2.9.2",
30
- "vue": "^3.5.30"
29
+ "reka-ui": "^2.9.6",
30
+ "vue": "^3.5.32"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@vue-macros/volar": "3.0.0-beta.8",
@@ -11,7 +11,21 @@ const props = withDefaults(defineProps<{
11
11
  label?: string
12
12
  description?: string
13
13
  placeholder?: string
14
+ /**
15
+ * Marks the field as required: enables native HTML5 `required` validation
16
+ * on the underlying input and (by default) renders a `*` next to the label.
17
+ * Use `hideRequiredMark` when the form already conveys required-ness
18
+ * through other means (e.g. all fields are required).
19
+ */
14
20
  required?: boolean
21
+ /**
22
+ * Suppress the `*` indicator next to the label without disabling the
23
+ * underlying HTML5 `required` validation. Useful for forms where every
24
+ * field is required so the marker would just add noise.
25
+ *
26
+ * @default false
27
+ */
28
+ hideRequiredMark?: boolean
15
29
  type?: InputType
16
30
  inputClass?: string
17
31
  singleLine?: boolean
@@ -30,7 +44,7 @@ const modelValue = defineModel<T>({ required: false })
30
44
  <slot name="label">
31
45
  {{ props.label }}
32
46
  </slot>
33
- <span v-if="props.required" class="text-red-500">*</span>
47
+ <span v-if="props.required && !props.hideRequiredMark" class="text-red-500">*</span>
34
48
  </div>
35
49
  <div class="text-xs text-neutral-500 dark:text-neutral-400" text-wrap>
36
50
  <slot name="description">
@@ -43,6 +57,7 @@ const modelValue = defineModel<T>({ required: false })
43
57
  v-model.number="modelValue"
44
58
  :type="props.type"
45
59
  :placeholder="props.placeholder"
60
+ :required="props.required"
46
61
  :class="props.inputClass"
47
62
  />
48
63
  <Input
@@ -50,6 +65,7 @@ const modelValue = defineModel<T>({ required: false })
50
65
  v-model="modelValue"
51
66
  :type="props.type"
52
67
  :placeholder="props.placeholder"
68
+ :required="props.required"
53
69
  :class="props.inputClass"
54
70
  />
55
71
  <textarea
@@ -57,6 +73,7 @@ const modelValue = defineModel<T>({ required: false })
57
73
  v-model="modelValue as string | undefined"
58
74
  :type="props.type"
59
75
  :placeholder="props.placeholder"
76
+ :required="props.required"
60
77
  :class="[
61
78
  props.inputClass,
62
79
  'focus:primary-300 dark:focus:primary-400/50 border-2 border-solid border-neutral-100 dark:border-neutral-900',
@@ -18,6 +18,12 @@ const props = withDefaults(defineProps<{
18
18
  variant?: InputVariant // Button style variant
19
19
  size?: InputSize // Button size variant
20
20
  theme?: InputTheme // Button theme
21
+ /**
22
+ * Forwarded to the underlying `<input>` element so the browser participates
23
+ * in form validation (HTML5 `:invalid` styling and submit blocking) without
24
+ * the consumer having to drop down to raw HTML.
25
+ */
26
+ required?: boolean
21
27
  }>(), {
22
28
  variant: 'primary',
23
29
  size: 'md',
@@ -69,6 +75,7 @@ const variantClasses: Record<InputVariant, Record<InputTheme, {
69
75
  <input
70
76
  v-model.number="modelValue"
71
77
  :type="props.type || 'text'"
78
+ :required="props.required"
72
79
  :class="[
73
80
  'transition-all duration-200 ease-in-out',
74
81
  'cursor-disabled:not-allowed',
@@ -80,6 +87,7 @@ const variantClasses: Record<InputVariant, Record<InputTheme, {
80
87
  <input
81
88
  v-model="modelValue"
82
89
  :type="props.type || 'text'"
90
+ :required="props.required"
83
91
  :class="[
84
92
  'transition-all duration-200 ease-in-out',
85
93
  'cursor-disabled:not-allowed',
@@ -13,11 +13,13 @@ const props = withDefaults(defineProps<{
13
13
  options: SelectTabOption[]
14
14
  disabled?: boolean
15
15
  readonly?: boolean
16
- size?: 'sm' | 'md'
16
+ size?: 'xs' | 'sm' | 'md' | 'w-xs' | 'w-sm' | 'w-md'
17
+ tabSpace?: 'compact' | 'spaced'
17
18
  }>(), {
18
19
  disabled: false,
19
20
  readonly: false,
20
21
  size: 'md',
22
+ tabSpace: 'spaced',
21
23
  })
22
24
 
23
25
  const modelValue = defineModel<T>({ required: true })
@@ -27,9 +29,17 @@ const itemCount = computed(() => props.options.length || 1)
27
29
  const isDisabled = computed(() => props.disabled || props.readonly)
28
30
 
29
31
  const sizeClasses = computed(() =>
30
- props.size === 'sm'
31
- ? ['py-2', 'px-3', 'text-xs', 'rounded-md', 'min-w-24']
32
- : ['py-2.5', 'px-3.5', 'text-sm', 'rounded-md', 'min-w-32'],
32
+ props.size === 'xs'
33
+ ? props.tabSpace === 'compact'
34
+ ? ['py-1', 'px-2', 'text-xs', 'rounded-md']
35
+ : ['py-1', 'px-2', 'text-xs', 'rounded-md', 'min-w-20']
36
+ : props.size === 'sm'
37
+ ? props.tabSpace === 'compact'
38
+ ? ['py-2', 'px-3', 'text-xs', 'rounded-md']
39
+ : ['py-2', 'px-3', 'text-xs', 'rounded-md', 'min-w-24']
40
+ : props.tabSpace === 'compact'
41
+ ? ['py-2.5', 'px-3.5', 'text-sm', 'rounded-md']
42
+ : ['py-2.5', 'px-3.5', 'text-sm', 'rounded-md', 'min-w-32'],
33
43
  )
34
44
 
35
45
  const rootStyle = computed(() => ({
@@ -51,9 +61,11 @@ const rootStyle = computed(() => ({
51
61
  'is-interacting',
52
62
  'relative', 'flex', 'items-stretch', 'rounded-lg',
53
63
  'overflow-hidden',
54
- 'bg-white-400/6', 'dark:bg-neutral-950/70',
55
- 'transition-[border-color,box-shadow,opacity]', 'duration-200', 'ease-out',
56
- isDisabled ? ['cursor-not-allowed', 'opacity-60'] : ['shadow-[0_14px_50px_-32px_rgba(0,0,0,0.55)]', 'backdrop-blur-sm'],
64
+ 'bg-neutral-400/6 dark:bg-neutral-950/70',
65
+ 'transition-[border-color,box-shadow,opacity] duration-200 ease-out',
66
+ isDisabled
67
+ ? ['cursor-not-allowed', 'opacity-60']
68
+ : ['shadow-[0_14px_50px_-32px_rgba(0,0,0,0.55)]', 'backdrop-blur-sm'],
57
69
  // before
58
70
  'before:bg-primary-300/50', 'dark:before:bg-primary-400/50',
59
71
  'before:rounded-md', 'sm:before:rounded-lg',
@@ -82,7 +94,9 @@ const rootStyle = computed(() => ({
82
94
  'transition-[color,background-color,border-color,transform]', 'duration-200', 'ease-out',
83
95
  'focus-visible:border-none', 'focus-visible:outline-none',
84
96
  sizeClasses,
85
- isDisabled ? 'pointer-events-none' : 'cursor-pointer',
97
+ isDisabled
98
+ ? 'pointer-events-none'
99
+ : 'cursor-pointer',
86
100
  // checked
87
101
  'data-[state=checked]:text-primary-950', 'dark:data-[state=checked]:text-primary-50',
88
102
  // unchecked
@@ -53,7 +53,12 @@ watch(input, () => {
53
53
  return
54
54
  }
55
55
 
56
- textareaHeight.value = `${textareaRef.value.scrollHeight}px`
56
+ // NOTICE: not sure why 4px is required but if not added, when
57
+ // input happened and placeholder now disappeared, the textarea will shrink
58
+ // a little bit and cause the input box to shake.
59
+ // TODO: find out the root cause and remove this magic number, or at least
60
+ // reference a more specific source.
61
+ textareaHeight.value = `${textareaRef.value.scrollHeight + 4}px`
57
62
  })
58
63
  }, { immediate: true })
59
64
  </script>
@@ -1,3 +1,4 @@
1
1
  export { default as Collapsible } from './collapsible.vue'
2
2
  export { default as Screen } from './screen.vue'
3
3
  export { default as Skeleton } from './skeleton.vue'
4
+ export { default as Truncatable } from './truncatable.vue'
@@ -0,0 +1,178 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Amazing work by Derek Morash on CSS line-clamp animation.
4
+ *
5
+ * https://derekmorash.com/writing/css-line-clamp-animation/
6
+ */
7
+
8
+ import { useResizeObserver } from '@vueuse/core'
9
+ import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef } from 'vue'
10
+
11
+ defineOptions({
12
+ name: 'Truncatable',
13
+ })
14
+
15
+ const props = withDefaults(defineProps<TruncatableProps>(), {
16
+ lineClamp: 3,
17
+ })
18
+
19
+ /**
20
+ * Props for a text/content container that can be line-clamped and expanded.
21
+ */
22
+ interface TruncatableProps {
23
+ /**
24
+ * Maximum visible lines while collapsed.
25
+ *
26
+ * @default 3
27
+ */
28
+ lineClamp?: number
29
+ }
30
+
31
+ const contentRef = useTemplateRef<HTMLElement>('content')
32
+
33
+ /**
34
+ * Matches the CSS max-height transition so closing can finish before line-clamp
35
+ * is restored to the inner content.
36
+ */
37
+ const transitionDurationMs = 300
38
+
39
+ const expanded = shallowRef(false)
40
+ const lineClamped = shallowRef(true)
41
+ const closedHeight = shallowRef(0)
42
+ const openedHeight = shallowRef(0)
43
+ const isOverflowing = shallowRef(false)
44
+ const closeClampTimer = shallowRef<number>()
45
+
46
+ const normalizedLineClamp = computed(() => Math.max(1, Math.floor(props.lineClamp)))
47
+ const visibleHeight = computed(() => expanded.value ? openedHeight.value : closedHeight.value)
48
+ const contentStyle = computed(() => ({
49
+ '--truncatable-line-clamp': String(normalizedLineClamp.value),
50
+ '--truncatable-transition-duration': `${transitionDurationMs}ms`,
51
+ 'maxHeight': visibleHeight.value > 0 ? `${visibleHeight.value}px` : undefined,
52
+ }))
53
+ const containerRole = computed(() => isOverflowing.value ? 'button' : undefined)
54
+ const containerTabindex = computed(() => isOverflowing.value ? 0 : undefined)
55
+
56
+ function measureClampedHeight(element: HTMLElement) {
57
+ const previousDisplay = element.style.display
58
+ const previousOverflow = element.style.overflow
59
+ const previousWebkitBoxOrient = element.style.webkitBoxOrient
60
+ const previousWebkitLineClamp = element.style.webkitLineClamp
61
+
62
+ // DOM measurement needs the real rendered width, so temporarily apply the
63
+ // collapsed CSS to the visible content and restore the previous inline styles.
64
+ element.style.display = '-webkit-box'
65
+ element.style.overflow = 'hidden'
66
+ element.style.webkitBoxOrient = 'vertical'
67
+ element.style.webkitLineClamp = String(normalizedLineClamp.value)
68
+
69
+ const height = element.getBoundingClientRect().height
70
+
71
+ element.style.display = previousDisplay
72
+ element.style.overflow = previousOverflow
73
+ element.style.webkitBoxOrient = previousWebkitBoxOrient
74
+ element.style.webkitLineClamp = previousWebkitLineClamp
75
+
76
+ return height
77
+ }
78
+
79
+ async function measureHeights() {
80
+ await nextTick()
81
+
82
+ const element = contentRef.value
83
+ if (!element)
84
+ return
85
+
86
+ const nextClosedHeight = measureClampedHeight(element)
87
+ const nextOpenedHeight = element.scrollHeight
88
+
89
+ closedHeight.value = nextClosedHeight
90
+ openedHeight.value = nextOpenedHeight
91
+ isOverflowing.value = nextOpenedHeight > nextClosedHeight + 1
92
+
93
+ if (!isOverflowing.value) {
94
+ expanded.value = false
95
+ lineClamped.value = true
96
+ }
97
+ }
98
+
99
+ function toggleExpanded() {
100
+ if (!isOverflowing.value)
101
+ return
102
+
103
+ if (closeClampTimer.value != null)
104
+ window.clearTimeout(closeClampTimer.value)
105
+
106
+ if (expanded.value) {
107
+ expanded.value = false
108
+ closeClampTimer.value = window.setTimeout(() => {
109
+ lineClamped.value = true
110
+ closeClampTimer.value = undefined
111
+ }, transitionDurationMs)
112
+ return
113
+ }
114
+
115
+ lineClamped.value = false
116
+ expanded.value = !expanded.value
117
+ }
118
+
119
+ function handleContainerKeydown(event: KeyboardEvent) {
120
+ if (event.key !== 'Enter' && event.key !== ' ')
121
+ return
122
+
123
+ event.preventDefault()
124
+ toggleExpanded()
125
+ }
126
+
127
+ onMounted(measureHeights)
128
+ onBeforeUnmount(() => {
129
+ if (closeClampTimer.value != null)
130
+ window.clearTimeout(closeClampTimer.value)
131
+ })
132
+ useResizeObserver(contentRef, measureHeights)
133
+ </script>
134
+
135
+ <template>
136
+ <div
137
+ class="truncatable"
138
+ :class="{ 'truncatable--interactive': isOverflowing }"
139
+ :style="contentStyle"
140
+ :role="containerRole"
141
+ :tabindex="containerTabindex"
142
+ :aria-expanded="isOverflowing ? expanded : undefined"
143
+ @click="toggleExpanded"
144
+ @keydown="handleContainerKeydown"
145
+ >
146
+ <div
147
+ ref="content"
148
+ class="truncatable__inner"
149
+ :class="{ 'truncatable__inner--line-clamped': lineClamped }"
150
+ >
151
+ <slot />
152
+ </div>
153
+ </div>
154
+ </template>
155
+
156
+ <style scoped>
157
+ .truncatable {
158
+ width: 100%;
159
+ overflow: hidden;
160
+ transition: max-height var(--truncatable-transition-duration) ease;
161
+ }
162
+
163
+ .truncatable--interactive {
164
+ cursor: pointer;
165
+ }
166
+
167
+ .truncatable:focus-visible {
168
+ outline: 2px solid currentcolor;
169
+ outline-offset: 2px;
170
+ }
171
+
172
+ .truncatable__inner--line-clamped {
173
+ display: -webkit-box;
174
+ overflow: hidden;
175
+ -webkit-box-orient: vertical;
176
+ -webkit-line-clamp: var(--truncatable-line-clamp);
177
+ }
178
+ </style>
@@ -49,7 +49,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
49
49
  'rounded-lg',
50
50
  'backdrop-blur-md',
51
51
  'bg-primary-500/15 hover:bg-primary-500/20 active:bg-primary-500/30 dark:bg-primary-700/30 dark:hover:bg-primary-700/40 dark:active:bg-primary-700/30',
52
- 'focus:ring-primary-300/60 dark:focus:ring-primary-600/30',
52
+ 'focus:ring-none',
53
53
  'border-2 border-solid border-primary-500/5 dark:border-primary-900/40',
54
54
  'text-primary-950 dark:text-primary-100',
55
55
  'focus:ring-2',
@@ -62,7 +62,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
62
62
  'rounded-lg',
63
63
  'backdrop-blur-md',
64
64
  'bg-neutral-100/55 hover:bg-neutral-400/20 active:bg-neutral-400/30 dark:bg-neutral-700/60 dark:hover:bg-neutral-700/80 dark:active:bg-neutral-700/60',
65
- 'focus:ring-neutral-300/30 dark:focus:ring-neutral-600/60 dark:focus:ring-neutral-600/30',
65
+ 'focus:ring-none',
66
66
  'border-2 border-solid border-neutral-300/30 dark:border-neutral-700/30',
67
67
  'text-neutral-950 dark:text-neutral-100',
68
68
  'focus:ring-2',
@@ -76,7 +76,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
76
76
  'backdrop-blur-md',
77
77
  'hover:bg-neutral-50/50 active:bg-neutral-50/90 hover:dark:bg-neutral-800/50 active:dark:bg-neutral-800/90',
78
78
  'border-2 border-solid border-neutral-100/60 dark:border-neutral-800/30',
79
- 'focus:ring-2 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/60 dark:focus:ring-neutral-600/30',
79
+ 'focus:ring-none',
80
80
  ],
81
81
  nonToggled: 'bg-neutral-50/70 dark:bg-neutral-800/70 text-neutral-500 dark:text-neutral-400',
82
82
  toggled: 'bg-white/90 dark:bg-neutral-500/70 ring-neutral-300/30 dark:ring-neutral-600/60 ring-2 dark:ring-neutral-600/30 text-primary-500 dark:text-primary-100',
@@ -88,7 +88,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
88
88
  'rounded-lg',
89
89
  'backdrop-blur-md',
90
90
  'bg-red-500/15 hover:bg-red-500/20 active:bg-red-500/30 dark:bg-red-700/30 dark:hover:bg-red-700/40 dark:active:bg-red-700/30',
91
- 'focus:ring-2 focus:ring-red-300/30 dark:focus:ring-red-600/60 dark:focus:ring-red-600/30',
91
+ 'focus:ring-none',
92
92
  'border-2 border-solid border-red-200/30 dark:border-red-900/30',
93
93
  'text-red-950 dark:text-red-100',
94
94
  ],
@@ -99,9 +99,9 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
99
99
  default: [
100
100
  'rounded-lg',
101
101
  'backdrop-blur-md',
102
- 'bg-amber-400/20 hover:bg-amber-400/25 active:bg-amber-400/35 dark:bg-amber-500/20 dark:hover:bg-amber-500/30 dark:active:bg-amber-500/35',
103
- 'focus:ring-2 focus:ring-amber-300/40 dark:focus:ring-amber-400/40',
104
- 'border-2 border-solid border-amber-300/40 dark:border-amber-500/40',
102
+ 'bg-amber-400/20 hover:bg-amber-400/25 active:bg-amber-400/35 dark:bg-amber-500/20 dark:hover:bg-amber-500/30 dark:active:bg-amber-500/20',
103
+ 'focus:ring-none',
104
+ 'border-2 border-solid border-amber-300/30 dark:border-amber-500/15',
105
105
  'text-amber-900 dark:text-amber-50',
106
106
  ],
107
107
  },
@@ -109,6 +109,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
109
109
  'pure': {
110
110
  default: {
111
111
  default: [
112
+ 'rounded-lg',
112
113
  'bg-transparent',
113
114
  'text-neutral-900 dark:text-neutral-50',
114
115
  '!px-0 !py-0',
@@ -118,10 +119,11 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
118
119
  'ghost': {
119
120
  default: {
120
121
  default: [
122
+ 'rounded-lg',
121
123
  'bg-transparent',
122
124
  'hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50',
123
125
  'text-neutral-500 dark:text-neutral-400',
124
- 'focus:ring-2 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/30',
126
+ 'focus:ring-none',
125
127
  ],
126
128
  },
127
129
  },
@@ -157,11 +157,10 @@ async function copyContent() {
157
157
  <template>
158
158
  <div
159
159
  :class="[
160
- 'relative w-full rounded-xl border-2 border-red-200/70 bg-red-50/60 backdrop-blur-md p-1',
161
- 'dark:border-red-900/50 dark:bg-red-950/25',
160
+ 'relative w-full rounded-lg bg-red-50/60 dark:bg-red-950/25 backdrop-blur-md p-1',
162
161
  ]"
163
162
  >
164
- <div :class="['absolute right-4 -translate-x-full top-2 z-10']">
163
+ <div :class="['absolute right-2 -translate-x-full top-2 z-10']">
165
164
  <Button
166
165
  v-if="showCopyButton"
167
166
  size="sm"
@@ -172,9 +171,7 @@ async function copyContent() {
172
171
  :aria-label="copied ? copiedButtonLabel : copyButtonLabel"
173
172
  @click="copyContent"
174
173
  />
175
- </div>
176
174
 
177
- <div :class="['absolute right-2 top-2 z-10']">
178
175
  <Button
179
176
  v-if="showFeedbackButton"
180
177
  size="sm"
@@ -191,13 +188,12 @@ async function copyContent() {
191
188
  type="auto"
192
189
  :class="[
193
190
  'relative w-full overflow-hidden rounded-xl',
194
- 'bg-neutral-50/80 dark:bg-neutral-950/80',
195
191
  ...heightPresetClasses[heightPreset],
196
192
  ]"
197
193
  >
198
194
  <ScrollAreaViewport :class="['h-full w-full']">
199
195
  <div :class="['flex flex-col gap-2 p-3 text-xs']">
200
- <div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono font-semibold text-red-700 leading-relaxed dark:text-red-300']">
196
+ <div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono text-red-700 leading-relaxed dark:text-red-300']">
201
197
  {{ resolvedErrorName || 'Error' }}
202
198
  <span v-if="resolvedMessage">
203
199
  : {{ resolvedMessage }}
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ import { onErrorCaptured, ref } from 'vue'
3
+
4
+ import ContainerError from './container-error.vue'
5
+
6
+ /**
7
+ * Error boundary that contains exceptions thrown during the render or setup of
8
+ * descendant components and renders a fallback UI instead of letting the error
9
+ * propagate to the host (which would tear the surrounding layout down).
10
+ *
11
+ * Use when:
12
+ * - Wrapping a `<RouterView>` so a single broken route never blanks the whole app shell.
13
+ * - Wrapping any subtree where partial failure is preferable to total failure.
14
+ *
15
+ * Expects:
16
+ * - Children may throw synchronously during render or in `setup`. Async rejections
17
+ * that bubble out of unhandled promises are NOT caught — Vue does not surface those
18
+ * to `onErrorCaptured`. Use `app.config.errorHandler` for those.
19
+ *
20
+ * Returns:
21
+ * - Default slot when there is no captured error.
22
+ * - `fallback` slot (or built-in `ContainerError` UI) when an error has been captured.
23
+ * The boundary suppresses error propagation by returning `false` from
24
+ * `onErrorCaptured`, so the parent stays mounted.
25
+ */
26
+
27
+ interface ErrorBoundaryProps {
28
+ /**
29
+ * Optional title shown above the error details. Useful when the boundary
30
+ * wraps a recognizable region (e.g. "Stage failed to load").
31
+ */
32
+ title?: string
33
+ /**
34
+ * Whether to show the built-in retry button. Retry remounts the default slot
35
+ * by bumping an internal key, giving the subtree a fresh chance to render.
36
+ * @default true
37
+ */
38
+ retryable?: boolean
39
+ /**
40
+ * Label for the retry button.
41
+ * @default 'Try again'
42
+ */
43
+ retryLabel?: string
44
+ }
45
+
46
+ const props = withDefaults(defineProps<ErrorBoundaryProps>(), {
47
+ retryable: true,
48
+ retryLabel: 'Try again',
49
+ })
50
+
51
+ const emit = defineEmits<{
52
+ (e: 'error', err: unknown, instance: unknown, info: string): void
53
+ (e: 'retry'): void
54
+ }>()
55
+
56
+ const capturedError = ref<unknown>(null)
57
+ const capturedInfo = ref<string>('')
58
+ const renderKey = ref(0)
59
+
60
+ onErrorCaptured((err, instance, info) => {
61
+ capturedError.value = err
62
+ capturedInfo.value = info
63
+ emit('error', err, instance, info)
64
+ // Stop propagation so the host layout keeps rendering.
65
+ return false
66
+ })
67
+
68
+ function retry() {
69
+ capturedError.value = null
70
+ capturedInfo.value = ''
71
+ renderKey.value += 1
72
+ emit('retry')
73
+ }
74
+
75
+ defineExpose({ retry, hasError: () => capturedError.value != null })
76
+ </script>
77
+
78
+ <template>
79
+ <template v-if="capturedError == null">
80
+ <slot :key="renderKey" />
81
+ </template>
82
+ <template v-else>
83
+ <slot
84
+ name="fallback"
85
+ :error="capturedError"
86
+ :info="capturedInfo"
87
+ :retry="retry"
88
+ >
89
+ <div :class="['flex flex-col gap-3 p-4 max-w-2xl mx-auto']">
90
+ <div v-if="props.title" :class="['text-base font-semibold text-red-700 dark:text-red-300']">
91
+ {{ props.title }}
92
+ </div>
93
+ <div v-if="capturedInfo" :class="['text-xs text-neutral-500 dark:text-neutral-400']">
94
+ During: {{ capturedInfo }}
95
+ </div>
96
+ <ContainerError
97
+ :error="capturedError"
98
+ height-preset="lg"
99
+ />
100
+ <div v-if="props.retryable" :class="['flex justify-end']">
101
+ <button
102
+ type="button"
103
+ :class="[
104
+ 'px-3 py-1.5 rounded-lg text-sm font-medium',
105
+ 'bg-red-100 hover:bg-red-200 text-red-800',
106
+ 'dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200',
107
+ 'transition-colors',
108
+ ]"
109
+ @click="retry"
110
+ >
111
+ {{ props.retryLabel }}
112
+ </button>
113
+ </div>
114
+ </div>
115
+ </slot>
116
+ </template>
117
+ </template>
@@ -2,4 +2,5 @@ export { default as Button } from './button.vue'
2
2
  export { default as Callout } from './callout.vue'
3
3
  export { default as ContainerError } from './container-error.vue'
4
4
  export { default as DoubleCheckButton } from './double-check-button.vue'
5
+ export { default as ErrorBoundary } from './error-boundary.vue'
5
6
  export { default as Progress } from './progress.vue'
@@ -1,7 +1,12 @@
1
1
  import { useDark, useToggle } from '@vueuse/core'
2
2
 
3
+ import { LocalStorageShim } from '../utils'
4
+
3
5
  const isDark = useDark({
4
6
  disableTransition: true,
7
+ // NOTICE: for histoire, used in packages/stage-ui, localStorage global variable exists but `storage.getItem is not a function` wil
8
+ // thrown, here we added LocalStorageShim to avoid this issue, and it will fallback to real localStorage when it's available.
9
+ storage: 'localStorage' in globalThis && localStorage != null && 'getItem' in localStorage && typeof localStorage.getItem === 'function' ? localStorage : new LocalStorageShim(),
5
10
  })
6
11
 
7
12
  const toggleDark = useToggle(isDark)
@@ -0,0 +1 @@
1
+ export { LocalStorageShim } from './shim'
@@ -0,0 +1 @@
1
+ export { LocalStorageShim } from './local-storage'
@@ -0,0 +1,27 @@
1
+ export class LocalStorageShim implements Storage {
2
+ private map = new Map<string, any>()
3
+
4
+ clear() {
5
+ this.map.clear()
6
+ }
7
+
8
+ getItem(key: string) {
9
+ return this.map.get(key) || null
10
+ }
11
+
12
+ key(index: number) {
13
+ return Array.from(this.map.keys())[index] || null
14
+ }
15
+
16
+ get length() {
17
+ return this.map.size
18
+ }
19
+
20
+ setItem(key: string, value: string) {
21
+ this.map.set(key, value)
22
+ }
23
+
24
+ removeItem(key: string) {
25
+ this.map.delete(key)
26
+ }
27
+ }