@meistrari/tela-build 1.45.0 → 1.47.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.
@@ -0,0 +1,99 @@
1
+ import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
2
+ import * as HeadingTabsStories from './heading-tabs.stories.ts';
3
+
4
+ <Meta of={HeadingTabsStories} />
5
+
6
+ # TelaHeadingTabs
7
+
8
+ A heading-styled tab switcher. Each option renders as a heading-typography button (`md` → `heading-h3-semibold`, `lg` → `heading-h2-semibold`) — the active tab in `text-primary`, inactive tabs in `text-tertiary` with a hover transition to `text-secondary`. Use it to switch between sections or lists where the tabs double as section headings (e.g. a dashboard's "Recents / Team activity / Favorites").
9
+
10
+ This is distinct from `TelaTabs` (the small, underlined Reka-based tab bar with an indicator) and `TelaSegmentToggle` (the pill segmented control). Reach for `TelaHeadingTabs` when the tabs themselves are the page's section headings.
11
+
12
+ ## Examples
13
+
14
+ ### Basic Usage
15
+
16
+ ```vue
17
+ <script setup>
18
+ import { ref } from 'vue'
19
+
20
+ const selectedTab = ref('recents')
21
+ const options = [
22
+ { tab: 'recents', label: 'Recents' },
23
+ { tab: 'everyone', label: 'Team activity' },
24
+ { tab: 'favorites', label: 'Favorites' }
25
+ ]
26
+ </script>
27
+
28
+ <template>
29
+ <TelaHeadingTabs v-model="selectedTab" :options="options" />
30
+ </template>
31
+ ```
32
+
33
+ ### Heading Sizes
34
+
35
+ ```vue
36
+ <!-- Default (md) → heading-h3-semibold -->
37
+ <TelaHeadingTabs v-model="selectedTab" :options="options" />
38
+
39
+ <!-- Larger (lg) → heading-h2-semibold -->
40
+ <TelaHeadingTabs v-model="selectedTab" :options="options" size="lg" />
41
+ ```
42
+
43
+ ### Conditionally Hidden Tab
44
+
45
+ Use the per-option `hidden` flag instead of conditionally building the array — keeps the tab list stable and the markup declarative.
46
+
47
+ ```vue
48
+ <TelaHeadingTabs
49
+ v-model="selectedTab"
50
+ :options="[
51
+ { tab: 'recents', label: 'Recents' },
52
+ { tab: 'everyone', label: 'Team activity' },
53
+ { tab: 'favorites', label: 'Favorites', hidden: !hasFavorites }
54
+ ]"
55
+ />
56
+ ```
57
+
58
+ ### Custom Styling
59
+
60
+ ```vue
61
+ <TelaHeadingTabs
62
+ v-model="selectedTab"
63
+ :options="options"
64
+ class="gap-6"
65
+ tab-class="uppercase"
66
+ />
67
+ ```
68
+
69
+ ## Props
70
+
71
+ <ArgTypes />
72
+
73
+ ```typescript
74
+ interface HeadingTabsOption {
75
+ tab: string
76
+ label: string
77
+ hidden?: boolean
78
+ }
79
+
80
+ type HeadingTabsProps = {
81
+ modelValue: string
82
+ options: HeadingTabsOption[]
83
+ size?: 'md' | 'lg'
84
+ class?: string
85
+ tabClass?: string
86
+ }
87
+ ```
88
+
89
+ ## Events
90
+
91
+ - `update:modelValue` - Emitted when a tab is selected with the new tab value (via `v-model`)
92
+
93
+ ## Features
94
+
95
+ - **Heading typography**: `md` → `heading-h3-semibold` (default), `lg` → `heading-h2-semibold`
96
+ - **Active/inactive states**: `text-primary` active, `text-tertiary` inactive with hover to `text-secondary`
97
+ - **v-model binding**: Two-way bound selected tab
98
+ - **Per-option visibility**: Hide tabs declaratively with `hidden`
99
+ - **Customizable**: Override container and per-button classes
@@ -0,0 +1,111 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3'
2
+ import { ref, watch } from 'vue'
3
+
4
+ import HeadingTabs from './heading-tabs.vue'
5
+
6
+ const meta: Meta<typeof HeadingTabs> = {
7
+ title: 'Core/HeadingTabs',
8
+ component: HeadingTabs,
9
+ parameters: {
10
+ layout: 'centered',
11
+ docs: {
12
+ description: {
13
+ component: 'A heading-styled tab switcher. Renders each option as a heading-typography button, with the active tab in `text-primary` and inactive tabs in `text-tertiary`. Useful for switching between sections or lists on a dashboard where the tabs double as section headings. Supports v-model binding, two heading sizes (`md` → `heading-h3-semibold`, `lg` → `heading-h2-semibold`), and per-option `hidden`.',
14
+ },
15
+ },
16
+ },
17
+ argTypes: {
18
+ modelValue: {
19
+ control: 'text',
20
+ description: 'The currently selected tab value (v-model).',
21
+ },
22
+ options: {
23
+ control: 'object',
24
+ description: 'Array of tab options. Each option has a `tab` (value), `label`, and optional `hidden` flag.',
25
+ },
26
+ size: {
27
+ control: 'select',
28
+ options: ['md', 'lg'],
29
+ description: 'Heading size. `md` → `heading-h3-semibold` (default), `lg` → `heading-h2-semibold`.',
30
+ },
31
+ class: {
32
+ control: 'text',
33
+ description: 'Custom CSS classes applied to the tabs container.',
34
+ },
35
+ tabClass: {
36
+ control: 'text',
37
+ description: 'Custom CSS classes applied to each tab button.',
38
+ },
39
+ },
40
+ args: {
41
+ options: [
42
+ { tab: 'recents', label: 'Recents' },
43
+ { tab: 'everyone', label: 'Team activity' },
44
+ { tab: 'favorites', label: 'Favorites' },
45
+ ],
46
+ modelValue: 'recents',
47
+ size: 'md',
48
+ },
49
+ render: (args) => {
50
+ return {
51
+ components: { HeadingTabs },
52
+ setup() {
53
+ const value = ref<string>(args.modelValue || '')
54
+
55
+ watch(
56
+ () => args.modelValue,
57
+ (val) => {
58
+ value.value = val || ''
59
+ },
60
+ )
61
+
62
+ return { args, value }
63
+ },
64
+ template: `
65
+ <div style="padding: 20px; display: flex; flex-direction: column; gap: 16px;">
66
+ <HeadingTabs
67
+ v-model="value"
68
+ :options="args.options"
69
+ :size="args.size"
70
+ :class="args.class"
71
+ :tab-class="args.tabClass"
72
+ />
73
+ <div style="font-family: monospace; font-size: 12px;">Selected: {{ value }}</div>
74
+ </div>
75
+ `,
76
+ }
77
+ },
78
+ }
79
+
80
+ export default meta
81
+
82
+ type Story = StoryObj<typeof meta>
83
+
84
+ export const Default: Story = {}
85
+
86
+ export const Large: Story = {
87
+ args: {
88
+ size: 'lg',
89
+ },
90
+ }
91
+
92
+ export const TwoTabs: Story = {
93
+ args: {
94
+ options: [
95
+ { tab: 'recents', label: 'Recents' },
96
+ { tab: 'everyone', label: 'Team activity' },
97
+ ],
98
+ modelValue: 'recents',
99
+ },
100
+ }
101
+
102
+ export const WithHiddenTab: Story = {
103
+ args: {
104
+ options: [
105
+ { tab: 'recents', label: 'Recents' },
106
+ { tab: 'everyone', label: 'Team activity' },
107
+ { tab: 'favorites', label: 'Favorites', hidden: true },
108
+ ],
109
+ modelValue: 'recents',
110
+ },
111
+ }
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue'
3
+
4
+ interface Option {
5
+ tab: string
6
+ label: string
7
+ hidden?: boolean
8
+ }
9
+
10
+ const props = withDefaults(defineProps<{
11
+ options: Option[]
12
+ size?: 'md' | 'lg'
13
+ class?: HTMLAttributes['class']
14
+ tabClass?: HTMLAttributes['class']
15
+ }>(), {
16
+ size: 'md',
17
+ })
18
+
19
+ const selectedTab = defineModel<string>({ required: true })
20
+
21
+ const headingClass = computed(() => props.size === 'lg' ? 'heading-h2-semibold' : 'heading-h3-semibold')
22
+
23
+ const visibleOptions = computed(() => props.options.filter(option => !option.hidden))
24
+
25
+ function selectTab(tab: string) {
26
+ selectedTab.value = tab
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <div :class="cn('flex items-center gap-[12px]', props.class)">
32
+ <button
33
+ v-for="option in visibleOptions"
34
+ :key="option.tab"
35
+ type="button"
36
+ :class="cn(
37
+ headingClass,
38
+ selectedTab === option.tab ? 'text-primary' : 'text-tertiary duration-120 hover:text-secondary',
39
+ props.tabClass,
40
+ )"
41
+ @click="selectTab(option.tab)"
42
+ >
43
+ {{ option.label }}
44
+ </button>
45
+ </div>
46
+ </template>
@@ -85,6 +85,28 @@ const isPageInputFocused = ref(false)
85
85
  const isProgrammaticScroll = ref(false)
86
86
 
87
87
  const scale = ref(props.initialScale ?? (props.variant === 'minimal' ? 1 : 0.75))
88
+
89
+ const IMAGE_BASE_WIDTH_DEFAULT = 480
90
+ const IMAGE_BASE_WIDTH_MINIMAL = 256
91
+ const isImage = computed(() => !!props.file.fileType?.startsWith('image/'))
92
+ const imageBaseWidth = computed(() => (props.variant === 'minimal' ? IMAGE_BASE_WIDTH_MINIMAL : IMAGE_BASE_WIDTH_DEFAULT))
93
+ const imageDisplayWidth = computed(() => Math.round(imageBaseWidth.value * scale.value))
94
+
95
+ const imageWrapperStyle = computed(() => {
96
+ if (!isImage.value) {
97
+ return { width: props.variant === 'minimal' ? '256px' : '446px', maxWidth: '100%' }
98
+ }
99
+
100
+ if (props.variant === 'minimal') {
101
+ return { width: `${Math.round(scale.value * 100)}%`, maxWidth: 'none' }
102
+ }
103
+
104
+ return {
105
+ width: `${imageDisplayWidth.value}px`,
106
+ maxWidth: scale.value > 1 ? 'none' : '100%',
107
+ }
108
+ })
109
+
88
110
  const scrollContainerRef = ref<HTMLElement | null>(null)
89
111
  const isDragging = ref(false)
90
112
  const dragEnabled = ref(false)
@@ -545,7 +567,6 @@ function setupPdfObserver() {
545
567
  pdfObserver.value.observe(el)
546
568
  }
547
569
 
548
- // Render first pages immediately — don't wait for async observer callback
549
570
  visiblePages.value.add(1)
550
571
  void renderVisiblePages()
551
572
  }
@@ -669,12 +690,19 @@ watch(() => props.file, async (newFile, oldFile) => {
669
690
  pdfDocHandle.value = null
670
691
  pdfLoadError.value = null
671
692
 
693
+ dragEnabled.value = false
694
+ isDragging.value = false
695
+
672
696
  if (!newFile) {
673
697
  fileUrl.value = undefined
674
698
  isLoading.value = false
675
699
  return
676
700
  }
677
701
 
702
+ scale.value = newFile.fileType?.startsWith('image/')
703
+ ? (props.initialScale ?? 1)
704
+ : (props.initialScale ?? (props.variant === 'minimal' ? 1 : 0.75))
705
+
678
706
  isLoading.value = true
679
707
  error.value = null
680
708
 
@@ -897,8 +925,6 @@ watch([() => props.highlightText, () => props.highlightPage, () => props.highlig
897
925
  if (pdfDocHandle.value) {
898
926
  await reRenderAllPdfPages()
899
927
  }
900
- // When doc isn't loaded yet, keep pendingScrollPage — it will be
901
- // consumed by reRenderAllPdfPages().finally once the PDF renders.
902
928
  })
903
929
  </script>
904
930
 
@@ -906,10 +932,10 @@ watch([() => props.highlightText, () => props.highlightPage, () => props.highlig
906
932
  <div
907
933
  relative
908
934
  :data-pdf-preview="props.file.fileType === 'application/pdf'"
909
- :data-has-tabs="props.segmentTab && props.file.fileType !== 'application/pdf'"
935
+ :data-has-tabs="props.segmentTab === 'processed' && props.file.fileType !== 'application/pdf'"
910
936
  :class="cn('group', {
911
- 'data-[has-tabs=true]:pt-88px data-[pdf-preview=false]:pt-56px': variant === 'default',
912
- 'data-[has-tabs=true]:pt-42px data-[pdf-preview=false]:pt-32px': variant === 'minimal',
937
+ 'data-[has-tabs=true]:pt-88px data-[pdf-preview=false]:pt-0px': variant === 'default',
938
+ 'data-[has-tabs=true]:pt-42px data-[pdf-preview=false]:pt-0px': variant === 'minimal',
913
939
  })"
914
940
  overflow-y-auto no-scrollbar
915
941
  @mouseenter="handleContainerMouseEnter"
@@ -1119,7 +1145,7 @@ watch([() => props.highlightText, () => props.highlightPage, () => props.highlig
1119
1145
  <!-- PDF Content -->
1120
1146
  <template v-else-if="props.file.fileType === 'application/pdf'">
1121
1147
  <Motion
1122
- relative
1148
+ relative mb--48px
1123
1149
  class="group"
1124
1150
  :initial="{ opacity: 0, filter: 'blur(4px)', pointerEvents: 'none' }"
1125
1151
  :animate="{ opacity: 1, filter: 'blur(0px)', pointerEvents: 'auto' }"
@@ -1205,21 +1231,29 @@ watch([() => props.highlightText, () => props.highlightPage, () => props.highlig
1205
1231
  || props.file.fileType?.startsWith('video/')
1206
1232
  || props.file.fileType?.startsWith('audio/')"
1207
1233
  >
1208
- <div
1209
- mx-auto
1210
- flex items-center justify-center
1211
- :class="cn('h-[calc(100%-56px)]', {
1212
- 'w-446px': variant === 'default',
1213
- 'w-256px': variant === 'minimal',
1214
- })"
1215
- >
1234
+ <div relative h-full w-full mb--48px>
1216
1235
  <div
1217
- mx-46px border-0.5px rounded-12px overflow-hidden
1218
- class="border-[#000]/8 box-shadow-[0_3px_24px_0_rgba(214,218,224,0.24),0_12px_52px_0_rgba(214,218,224,0.16)]"
1236
+ ref="scrollContainerRef"
1237
+ absolute inset-0 select-none overflow-auto no-scrollbar flex
1238
+ class="[justify-content:safe_center] [align-items:safe_center]"
1239
+ :class="cn(
1240
+ variant === 'minimal' ? 'px-16px py-16px' : 'px-46px py-24px',
1241
+ isImage && dragEnabled ? (isDragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-default',
1242
+ )"
1243
+ @mousedown="handleMouseDown"
1244
+ @mousemove="handleMouseMove"
1245
+ @mouseup="handleMouseUp"
1246
+ @mouseleave="handleMouseLeave"
1219
1247
  >
1220
- <img v-if="props.file.fileType?.startsWith('image/')" :src="fileUrl!" width="100%" :alt="props.file.fileName">
1221
- <video v-else-if="props.file.fileType?.startsWith('video/')" :src="fileUrl!" width="100%" controls />
1222
- <audio v-else-if="props.file.fileType?.startsWith('audio/')" :src="fileUrl!" controls style="width: 100%;" />
1248
+ <div
1249
+ flex-shrink-0 border-0.5px rounded-12px overflow-hidden
1250
+ class="border-[#000]/8 box-shadow-[0_3px_24px_0_rgba(214,218,224,0.24),0_12px_52px_0_rgba(214,218,224,0.16)]"
1251
+ :style="imageWrapperStyle"
1252
+ >
1253
+ <img v-if="props.file.fileType?.startsWith('image/')" :src="fileUrl!" width="100%" :alt="props.file.fileName" :draggable="false">
1254
+ <video v-else-if="props.file.fileType?.startsWith('video/')" :src="fileUrl!" width="100%" controls />
1255
+ <audio v-else-if="props.file.fileType?.startsWith('audio/')" :src="fileUrl!" controls style="width: 100%;" />
1256
+ </div>
1223
1257
  </div>
1224
1258
  </div>
1225
1259
  </template>
@@ -57,11 +57,13 @@ onMounted(() => {
57
57
  })
58
58
 
59
59
  const isPdf = computed(() => props.file.fileType === 'application/pdf')
60
+ const isImage = computed(() => !!props.file.fileType?.startsWith('image/'))
61
+ const canZoom = computed(() => (isPdf.value ? props.totalPages > 0 : isImage.value))
60
62
 
61
63
  const actions = computed(() => ({
62
- zoomIn: props.variant === 'default' && props.totalPages > 0 && isPdf.value && props.segmentTab !== 'processed',
63
- zoomOut: props.variant === 'default' && props.totalPages > 0 && isPdf.value && props.segmentTab !== 'processed',
64
- handMode: props.variant === 'default' && isPdf.value && props.segmentTab !== 'processed',
64
+ zoomIn: props.variant === 'default' && canZoom.value && props.segmentTab !== 'processed',
65
+ zoomOut: props.variant === 'default' && canZoom.value && props.segmentTab !== 'processed',
66
+ handMode: props.variant === 'default' && (isPdf.value || isImage.value) && props.segmentTab !== 'processed',
65
67
  fullscreen: showFullscreenButton.value,
66
68
  }))
67
69
 
@@ -130,7 +132,7 @@ const computedWidthContent = computed(() => {
130
132
  <div flex items-center justify-center gap-4px>
131
133
  <TelaTooltipGroup>
132
134
  <div flex items-center justify-center gap-2px>
133
- <template v-if="variant === 'minimal' || !isPdf">
135
+ <template v-if="variant === 'minimal'">
134
136
  <TelaTooltipGroupTrigger variant="multiline">
135
137
  <button
136
138
  w-40px h-40px
@@ -303,7 +305,7 @@ const computedWidthContent = computed(() => {
303
305
  </div>
304
306
  </template>
305
307
 
306
- <template v-if="variant === 'default' && isPdf">
308
+ <template v-if="variant === 'default'">
307
309
  <div aria-hidden h-24px w-0.5px class="bg-white/16" />
308
310
 
309
311
  <div pl-12px pr-14px>
@@ -91,9 +91,9 @@ const firstColumnWidth = computed(() => {
91
91
  return '82px'
92
92
  }
93
93
 
94
- const minWidth = 82
94
+ const minWidth = 56
95
95
  const maxLength = Math.max(...allTexts.map(text => text.length))
96
- const charWidth = 7.5
96
+ const charWidth = 7
97
97
 
98
98
  const width = Math.max(minWidth, maxLength * charWidth)
99
99
 
@@ -28,7 +28,7 @@ function selectTab(value: string) {
28
28
 
29
29
  <template>
30
30
  <div
31
- rounded-full flex items-center gap-2px select-none p-2px min-w-68px bg-gray-200
31
+ rounded-full flex items-center gap-2px select-none p-2px min-w-68px bg-lowered
32
32
  :class="[
33
33
  props.size === 'small' ? 'min-h-24px' : 'min-h-28px',
34
34
  props.disabled && 'opacity-50 pointer-events-none cursor-not-allowed',
@@ -47,11 +47,11 @@ function selectTab(value: string) {
47
47
  :disabled="props.disabled"
48
48
  @click="selectTab(option.value)"
49
49
  >
50
- <span relative z-10 :class="modelValue === option.value ? 'text-gray-900' : 'text-gray-700'">{{ option.label }}</span>
50
+ <span relative z-10 :class="modelValue === option.value ? 'text-primary' : 'text-secondary'">{{ option.label }}</span>
51
51
  <Motion
52
52
  v-if="modelValue === option.value"
53
53
  :layout-id="`${uniqueId}-tab-indicator`"
54
- absolute z-0 inset-0 bg-white shadow-tab rounded-inherit size-full
54
+ absolute z-0 inset-0 bg shadow-tab rounded-inherit size-full
55
55
  :transition="{ duration: 0.3, type: 'spring', bounce: 0 }"
56
56
  />
57
57
  </button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/tela-build",
3
- "version": "1.45.0",
3
+ "version": "1.47.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "app.config.ts",