@nexxtmove/ui 1.47.0 → 1.49.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/dist/nuxt.js CHANGED
@@ -64,6 +64,7 @@ var s = {
64
64
  NexxtDropdownButton: "components/organisms/DropdownButton/DropdownButton.vue",
65
65
  NexxtFontPicker: "components/organisms/FontPicker/FontPicker.vue",
66
66
  NexxtHeader: "components/organisms/Header/Header.vue",
67
+ NexxtIconPicker: "components/organisms/IconPicker/IconPicker.vue",
67
68
  NexxtModal: "components/organisms/Modal/Modal.vue",
68
69
  NexxtPickerPanel: "components/organisms/PickerPanel/PickerPanel.vue",
69
70
  NexxtProgressBar: "components/organisms/ProgressBar/ProgressBar.vue",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.47.0",
4
+ "version": "1.49.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -1,15 +1,27 @@
1
1
  <script lang="ts" setup>
2
- import { computed, ref, useTemplateRef, watch, onMounted, nextTick } from 'vue'
2
+ import { computed, ref, useTemplateRef, watch, watchEffect, onMounted, nextTick } from 'vue'
3
3
  import Icon, { type NexxtIconName } from '../../atoms/Icon/Icon.vue'
4
4
 
5
5
  export interface NexxtTabItem {
6
+ /** Visible text, the `v-model` value and the key; in icon-only mode the tab's accessible name. */
6
7
  label: string
8
+ /** Icon shown before the label, or on its own in icon-only mode. */
7
9
  icon?: NexxtIconName
10
+ /** Show only the icon for this tab. Overrides the tabs-wide `iconOnly` either way. */
11
+ iconOnly?: boolean
8
12
  }
9
13
 
10
14
  interface NexxtTabs {
15
+ /** The tabs, as plain labels or `{ label, icon, iconOnly }` objects. */
11
16
  tabs: (string | NexxtTabItem)[]
17
+ /** Accessible name of the tablist. */
12
18
  tablistLabel?: string
19
+ /**
20
+ * Show only the icon of every tab, as a near-square button. The label stays the `v-model`
21
+ * value and the accessible name, in a visually hidden span. A tab without an `icon` keeps its
22
+ * visible label.
23
+ */
24
+ iconOnly?: boolean
13
25
  }
14
26
 
15
27
  defineOptions({
@@ -31,15 +43,47 @@ const items = computed<NexxtTabItem[]>(() =>
31
43
  props.tabs.map((tab) => (typeof tab === 'string' ? { label: tab } : tab)),
32
44
  )
33
45
 
46
+ const wantsIconOnly = (item: NexxtTabItem) => item.iconOnly ?? props.iconOnly ?? false
47
+
48
+ interface TabView extends NexxtTabItem {
49
+ compact: boolean
50
+ /** The visible label; empty in icon-only mode, where it moves to a visually hidden span. */
51
+ text: string
52
+ spacing: string
53
+ }
54
+
55
+ // icon-only needs an icon to show; without one the tab falls back to its visible label. The
56
+ // labelled tab keeps `text` as the same bare text node it always was, so its markup is unchanged
57
+ const views = computed<TabView[]>(() =>
58
+ items.value.map((item) => {
59
+ const compact = Boolean(item.icon) && wantsIconOnly(item)
60
+ return {
61
+ ...item,
62
+ compact,
63
+ text: compact ? '' : item.label,
64
+ spacing: compact ? 'px-3' : 'gap-2 px-4',
65
+ }
66
+ }),
67
+ )
68
+
34
69
  const updateSlider = async () => {
35
70
  await nextTick()
36
71
  const index = items.value.findIndex((item) => item.label === selected.value)
37
72
  const button = buttonRefs.value[index]
38
73
  if (!button || !containerRef.value) return
39
74
 
40
- const left = button.offsetLeft
41
- const width = button.offsetWidth
42
- const right = containerRef.value.offsetWidth - left - width
75
+ // offsetLeft/offsetWidth round to whole pixels while label and icon widths are fractional, which
76
+ // left the pill up to half a pixel off its tab. The rects are exact but include any ancestor
77
+ // transform (a modal scaling in), so they are divided back to layout pixels
78
+ const container = containerRef.value
79
+ const box = container.getBoundingClientRect()
80
+ const target = button.getBoundingClientRect()
81
+ const layoutWidth = parseFloat(getComputedStyle(container).width)
82
+ const scale = layoutWidth > 0 && box.width > 0 ? box.width / layoutWidth : 1
83
+
84
+ const left = (target.left - box.left) / scale
85
+ const width = target.width / scale
86
+ const right = box.width / scale - left - width
43
87
 
44
88
  sliderStyle.value = {
45
89
  width: `${width}px`,
@@ -64,8 +108,21 @@ const onKeydown = (event: KeyboardEvent, index: number) => {
64
108
 
65
109
  watch(selected, async () => await updateSlider())
66
110
 
67
- // the overlay copy must stay the same width as the button, so re-measure when the tabs change
68
- watch(items, async () => await updateSlider())
111
+ // the overlay copy must stay the same width as the button, so re-measure whenever a tab's width
112
+ // can change: new tabs, or a switch between icon-only and labelled
113
+ watch(views, async () => await updateSlider())
114
+
115
+ // `process.env.NODE_ENV` stays in the library build, so the consumer's bundler decides whether
116
+ // this runs (as in NexxtIconPicker)
117
+ if (process.env.NODE_ENV !== 'production') {
118
+ watchEffect(() => {
119
+ const missing = items.value.filter((item) => !item.icon && wantsIconOnly(item))
120
+ if (missing.length) {
121
+ const labels = missing.map((item) => `"${item.label}"`).join(', ')
122
+ console.warn(`[NexxtTabs] icon-only tab without an \`icon\` shows its label: ${labels}.`)
123
+ }
124
+ })
125
+ }
69
126
 
70
127
  onMounted(async () => {
71
128
  await document.fonts.ready
@@ -94,14 +151,15 @@ onMounted(async () => {
94
151
 
95
152
  <!-- dark text buttons -->
96
153
  <button
97
- v-for="(item, index) in items"
154
+ v-for="(item, index) in views"
98
155
  :key="item.label"
99
156
  :ref="(el) => (buttonRefs[index] = el as HTMLButtonElement)"
100
157
  role="tab"
101
158
  :aria-selected="selected === item.label"
102
159
  :tabindex="selected === item.label ? 0 : -1"
103
- class="relative z-10 inline-flex h-9 cursor-pointer items-center gap-2 rounded-full px-4 small-normal"
160
+ class="relative z-10 inline-flex h-9 cursor-pointer items-center rounded-full small-normal"
104
161
  :class="[
162
+ item.spacing,
105
163
  // the dark label is what the pill wipe reveals, so it only drops out once the pill
106
164
  // has arrived — otherwise its anti-aliased edges halo around the white copy
107
165
  selected === item.label ? 'text-transparent' : 'text-gray-950',
@@ -111,8 +169,12 @@ onMounted(async () => {
111
169
  @click="selected = item.label"
112
170
  @keydown="onKeydown($event, index)"
113
171
  >
114
- <Icon v-if="item.icon" :name="item.icon" aria-hidden="true" class="body-normal" />
115
- {{ item.label }}
172
+ <template v-if="item.compact && item.icon">
173
+ <Icon :name="item.icon" aria-hidden="true" class="body-normal" />
174
+ <span class="sr-only">{{ item.label }}</span>
175
+ </template>
176
+ <Icon v-else-if="item.icon" :name="item.icon" aria-hidden="true" class="body-normal" />
177
+ {{ item.text }}
116
178
  </button>
117
179
 
118
180
  <!-- white text overlay, clipped to slider bounds; must mirror the buttons exactly -->
@@ -125,12 +187,13 @@ onMounted(async () => {
125
187
  :style="{ clipPath }"
126
188
  >
127
189
  <span
128
- v-for="item in items"
190
+ v-for="item in views"
129
191
  :key="item.label"
130
- class="flex h-9 items-center gap-2 rounded-full px-4 small-normal text-white"
192
+ class="flex h-9 items-center rounded-full small-normal text-white"
193
+ :class="item.spacing"
131
194
  >
132
195
  <Icon v-if="item.icon" :name="item.icon" class="body-normal" />
133
- {{ item.label }}
196
+ {{ item.text }}
134
197
  </span>
135
198
  </div>
136
199
  </div>