@proj-airi/ui 0.9.0-rc.1 → 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-rc.1",
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',
@@ -40,6 +40,7 @@ const props = withDefaults(defineProps<{
40
40
  contentWidth?: string | number
41
41
  shape?: 'rounded' | 'default'
42
42
  variant?: 'blurry' | 'default'
43
+ class?: string | string[]
43
44
  }>(), {
44
45
  placeholder: 'Select an option',
45
46
  disabled: false,
@@ -112,6 +113,7 @@ function toCssSize(value?: string | number): string | undefined {
112
113
  <SelectTrigger
113
114
  :class="[
114
115
  'group',
116
+ ...Array.isArray(props.class) ? props.class : [props.class],
115
117
  'w-full inline-flex items-center justify-between border px-3 leading-none h-9 gap-[5px] outline-none',
116
118
  props.shape === 'rounded' ? 'rounded-full' : 'rounded-lg',
117
119
  'text-sm text-neutral-700 dark:text-neutral-200 data-[placeholder]:text-neutral-400 dark:data-[placeholder]:text-neutral-500',
@@ -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>
@@ -30,6 +30,7 @@ const props = withDefaults(defineProps<ButtonProps>(), {
30
30
  disabled: false,
31
31
  loading: false,
32
32
  size: 'md',
33
+ shape: 'pill',
33
34
  theme: 'default',
34
35
  block: false,
35
36
  })
@@ -45,10 +46,10 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
45
46
  'primary': {
46
47
  default: {
47
48
  default: [
48
- 'rounded-xl',
49
+ 'rounded-lg',
49
50
  'backdrop-blur-md',
50
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',
51
- 'focus:ring-primary-300/60 dark:focus:ring-primary-600/30',
52
+ 'focus:ring-none',
52
53
  'border-2 border-solid border-primary-500/5 dark:border-primary-900/40',
53
54
  'text-primary-950 dark:text-primary-100',
54
55
  'focus:ring-2',
@@ -58,10 +59,10 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
58
59
  'secondary': {
59
60
  default: {
60
61
  default: [
61
- 'rounded-xl',
62
+ 'rounded-lg',
62
63
  'backdrop-blur-md',
63
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',
64
- 'focus:ring-neutral-300/30 dark:focus:ring-neutral-600/60 dark:focus:ring-neutral-600/30',
65
+ 'focus:ring-none',
65
66
  'border-2 border-solid border-neutral-300/30 dark:border-neutral-700/30',
66
67
  'text-neutral-950 dark:text-neutral-100',
67
68
  'focus:ring-2',
@@ -71,11 +72,11 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
71
72
  'secondary-muted': {
72
73
  default: {
73
74
  default: [
74
- 'rounded-xl',
75
+ 'rounded-lg',
75
76
  'backdrop-blur-md',
76
77
  'hover:bg-neutral-50/50 active:bg-neutral-50/90 hover:dark:bg-neutral-800/50 active:dark:bg-neutral-800/90',
77
78
  'border-2 border-solid border-neutral-100/60 dark:border-neutral-800/30',
78
- 'focus:ring-2 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/60 dark:focus:ring-neutral-600/30',
79
+ 'focus:ring-none',
79
80
  ],
80
81
  nonToggled: 'bg-neutral-50/70 dark:bg-neutral-800/70 text-neutral-500 dark:text-neutral-400',
81
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',
@@ -84,10 +85,10 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
84
85
  'danger': {
85
86
  default: {
86
87
  default: [
87
- 'rounded-xl',
88
+ 'rounded-lg',
88
89
  'backdrop-blur-md',
89
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',
90
- 'focus:ring-2 focus:ring-red-300/30 dark:focus:ring-red-600/60 dark:focus:ring-red-600/30',
91
+ 'focus:ring-none',
91
92
  'border-2 border-solid border-red-200/30 dark:border-red-900/30',
92
93
  'text-red-950 dark:text-red-100',
93
94
  ],
@@ -96,11 +97,11 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
96
97
  'caution': {
97
98
  default: {
98
99
  default: [
99
- 'rounded-xl',
100
+ 'rounded-lg',
100
101
  'backdrop-blur-md',
101
- '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',
102
- 'focus:ring-2 focus:ring-amber-300/40 dark:focus:ring-amber-400/40',
103
- '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',
104
105
  'text-amber-900 dark:text-amber-50',
105
106
  ],
106
107
  },
@@ -108,6 +109,7 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
108
109
  'pure': {
109
110
  default: {
110
111
  default: [
112
+ 'rounded-lg',
111
113
  'bg-transparent',
112
114
  'text-neutral-900 dark:text-neutral-50',
113
115
  '!px-0 !py-0',
@@ -117,10 +119,11 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
117
119
  'ghost': {
118
120
  default: {
119
121
  default: [
122
+ 'rounded-lg',
120
123
  'bg-transparent',
121
124
  'hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50',
122
125
  'text-neutral-500 dark:text-neutral-400',
123
- 'focus:ring-2 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/30',
126
+ 'focus:ring-none',
124
127
  ],
125
128
  },
126
129
  },
@@ -151,7 +154,7 @@ const baseClasses = computed(() => {
151
154
  const theme = variant[props.theme] || variant.default
152
155
 
153
156
  return [
154
- 'rounded-lg font-medium outline-none',
157
+ 'font-medium outline-none',
155
158
  'transition-all duration-200 ease-in-out',
156
159
  'disabled:cursor-not-allowed disabled:opacity-50',
157
160
  'backdrop-blur-md',
@@ -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
+ }