@nexxtmove/ui 1.7.0 → 1.8.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
@@ -51,6 +51,7 @@ var s = {
51
51
  NexxtTable: "components/Table/Table.vue",
52
52
  NexxtTabs: "components/Tabs/Tabs.vue",
53
53
  NexxtTemplateCard: "components/TemplateCard/TemplateCard.vue",
54
+ NexxtTextarea: "components/Textarea/Textarea.vue",
54
55
  NexxtTimelineEvent: "components/TimelineEvent/TimelineEvent.vue",
55
56
  NexxtTimelineLanes: "components/TimelineLanes/TimelineLanes.vue",
56
57
  NexxtTimelinePhaseblock: "components/TimelinePhaseblock/TimelinePhaseblock.vue",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.7.0",
4
+ "version": "1.8.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -0,0 +1,144 @@
1
+ <script lang="ts" setup>
2
+ import { computed, nextTick, useAttrs, useTemplateRef, watch } from 'vue'
3
+
4
+ const TEXTAREA_ID = 'textarea-' + Math.random().toString(36).slice(2, 10)
5
+
6
+ defineOptions({
7
+ name: 'NexxtTextarea',
8
+ // Same routing as NexxtInputField: `class` and `style` position the field in
9
+ // the consumer's layout and go to the wrapper, everything else describes the
10
+ // control and goes to the textarea. Nothing lands on both.
11
+ inheritAttrs: false,
12
+ })
13
+
14
+ export interface NexxtTextareaProps {
15
+ /** Placeholder shown while the field is empty. */
16
+ placeholder?: string
17
+ /** Label rendered above the field. */
18
+ label?: string
19
+ /** Put the field in its error state. */
20
+ error?: boolean
21
+ /** Message shown below the field while `error` is set. */
22
+ errorMessage?: string
23
+ /** Mark the label with an asterisk. */
24
+ required?: boolean
25
+ /**
26
+ * Visible rows, and the floor for `autosize`. The field is never shorter than
27
+ * a NexxtInputField, whatever this is set to.
28
+ */
29
+ rows?: number
30
+ /**
31
+ * Grow with the content instead of scrolling, between `rows` and `maxRows`.
32
+ * Off by default: a field that changes height while typing moves whatever sits
33
+ * below it, so it should be asked for rather than assumed.
34
+ */
35
+ autosize?: boolean
36
+ /** Upper bound for `autosize`, in rows. Unset means it grows indefinitely. */
37
+ maxRows?: number
38
+ /**
39
+ * Whether the field can be dragged taller by its corner grip. Named after the
40
+ * CSS property it sets, so it can grow to cover the other values later
41
+ * without changing what the two it has today mean.
42
+ *
43
+ * Ignored while `autosize` is on: the autosizer rewrites the height on every
44
+ * keystroke, so a dragged height would not survive the next character.
45
+ */
46
+ resize?: 'vertical' | 'none'
47
+ }
48
+
49
+ const {
50
+ label,
51
+ error = false,
52
+ errorMessage,
53
+ required = false,
54
+ rows = 3,
55
+ autosize = false,
56
+ maxRows,
57
+ resize = 'vertical',
58
+ } = defineProps<NexxtTextareaProps>()
59
+
60
+ // `null` is in the type because the field's normal job is editing a nullable
61
+ // database column, and an API that returns one should not force every call site
62
+ // to write `?? ''`. The default stays: it only fires for `undefined`, and it
63
+ // keeps an unbound field from starting out null.
64
+ const model = defineModel<string | null>({ default: '' })
65
+
66
+ const attrs = useAttrs()
67
+
68
+ const wrapperAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
69
+
70
+ const controlAttrs = computed(() => {
71
+ const rest: Record<string, unknown> = { ...attrs }
72
+ delete rest.class
73
+ delete rest.style
74
+ return rest
75
+ })
76
+
77
+ const textareaEl = useTemplateRef<HTMLTextAreaElement>('textareaEl')
78
+
79
+ /**
80
+ * Height is measured, not calculated: reset to `auto` first so `scrollHeight`
81
+ * reports the content height rather than the height already set. `maxRows` is
82
+ * converted through the element's own computed line-height so it follows the
83
+ * field's typography instead of a hardcoded number.
84
+ */
85
+ const fitToContent = () => {
86
+ const el = textareaEl.value
87
+ if (!el || !autosize) return
88
+
89
+ // Reset first, so scrollHeight reports the content height rather than the
90
+ // height already set.
91
+ el.style.height = 'auto'
92
+
93
+ const style = getComputedStyle(el)
94
+ const lineHeight = parseFloat(style.lineHeight) || 0
95
+ const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)
96
+ const border = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth)
97
+
98
+ // scrollHeight covers content plus padding, but `height` under border-box
99
+ // includes the border too. Without adding it the field renders a border's
100
+ // width too short -- small, but enough to sit lower than a NexxtInputField
101
+ // beside it.
102
+ const natural = el.scrollHeight + border
103
+ const cap = maxRows && lineHeight ? maxRows * lineHeight + padding + border : Infinity
104
+
105
+ el.style.height = `${Math.min(natural, cap)}px`
106
+ el.style.overflowY = natural > cap ? 'auto' : 'hidden'
107
+ }
108
+
109
+ // Covers programmatic changes as well as typing, and waits for the DOM so
110
+ // scrollHeight is measured after the new value is rendered.
111
+ watch(model, () => nextTick(fitToContent), { immediate: true, flush: 'post' })
112
+
113
+ defineExpose({ textareaEl, fitToContent })
114
+ </script>
115
+
116
+ <template>
117
+ <div class="inline-flex flex-col gap-1" :class="wrapperAttrs.class" :style="wrapperAttrs.style">
118
+ <label v-if="label" class="justify-start small-normal text-gray-700" :for="TEXTAREA_ID">
119
+ {{ label }}
120
+ <span v-if="required" class="text-brick-500">*</span>
121
+ </label>
122
+
123
+ <textarea
124
+ :id="TEXTAREA_ID"
125
+ ref="textareaEl"
126
+ v-model="model"
127
+ v-bind="controlAttrs"
128
+ :rows="rows"
129
+ :placeholder="placeholder"
130
+ class="min-h-10 w-full rounded-lg border px-3 py-2 small-normal text-gray-900 transition-colors outline-none placeholder:text-gray-400 focus:border-cornflower-blue-500"
131
+ :class="[
132
+ error ? 'border-brick-400' : 'border-gray-200',
133
+ // autosize wins: it owns the height, and a grip that fights it every
134
+ // keystroke is worse than no grip.
135
+ autosize || resize === 'none' ? 'resize-none' : 'resize-y',
136
+ ]"
137
+ @input="fitToContent"
138
+ />
139
+
140
+ <p v-if="error && errorMessage" class="small-normal text-brick-400">
141
+ {{ errorMessage }}
142
+ </p>
143
+ </div>
144
+ </template>
@@ -48,6 +48,7 @@
48
48
  "NexxtTable": "components/Table/Table.vue",
49
49
  "NexxtTabs": "components/Tabs/Tabs.vue",
50
50
  "NexxtTemplateCard": "components/TemplateCard/TemplateCard.vue",
51
+ "NexxtTextarea": "components/Textarea/Textarea.vue",
51
52
  "NexxtTimelineEvent": "components/TimelineEvent/TimelineEvent.vue",
52
53
  "NexxtTimelineLanes": "components/TimelineLanes/TimelineLanes.vue",
53
54
  "NexxtTimelinePhaseblock": "components/TimelinePhaseblock/TimelinePhaseblock.vue",
package/src/index.ts CHANGED
@@ -62,6 +62,7 @@ export { default as NexxtTabbedContent } from './components/TabbedContent/Tabbed
62
62
  export { default as NexxtTable } from './components/Table/Table.vue'
63
63
  export { default as NexxtTabs } from './components/Tabs/Tabs.vue'
64
64
  export { default as NexxtTemplateCard } from './components/TemplateCard/TemplateCard.vue'
65
+ export { default as NexxtTextarea } from './components/Textarea/Textarea.vue'
65
66
  export { default as NexxtTimelineEvent } from './components/TimelineEvent/TimelineEvent.vue'
66
67
  export { default as NexxtTimelineLanes } from './components/TimelineLanes/TimelineLanes.vue'
67
68
  export { default as NexxtTimelinePhaseblock } from './components/TimelinePhaseblock/TimelinePhaseblock.vue'