@nexxtmove/ui 1.42.0 → 1.43.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,254 @@
1
+ <script lang="ts">
2
+ import type {
3
+ EditorDimensionUnit,
4
+ EditorDimensionValue,
5
+ } from '../../atoms/EditorDimensionField/EditorDimensionField.vue'
6
+
7
+ /**
8
+ * The options the editor offers, in the order they are shown. The labels are
9
+ * not in here; they arrive through `messages`, keyed by token.
10
+ */
11
+ export const EDITOR_SIZE_OPTIONS = [
12
+ { token: 'auto' },
13
+ { token: 'fill' },
14
+ { token: 'fixed' },
15
+ ] as const
16
+
17
+ /** How a dimension is sized: by its content, filling its parent, or fixed. */
18
+ export type EditorSizeMode = (typeof EDITOR_SIZE_OPTIONS)[number]['token']
19
+
20
+ export interface EditorSizeValue {
21
+ mode: EditorSizeMode
22
+ /**
23
+ * The fixed dimension. Kept when another mode is picked, so switching back
24
+ * to `'fixed'` restores it; it only applies while `mode` is `'fixed'`.
25
+ */
26
+ fixed?: EditorDimensionValue
27
+ }
28
+
29
+ /** Overridable copy of the options and the field. Every key is optional. */
30
+ export interface EditorSizeMessages {
31
+ /** Label of the `auto` option. Defaults to `'Automatisch'`. */
32
+ auto?: string
33
+ /** Label of the `fill` option. Defaults to `'Vullen'`. */
34
+ fill?: string
35
+ /** Label of the `fixed` option. Defaults to `'Vast'`. */
36
+ fixed?: string
37
+ /**
38
+ * Accessible name of the `auto` option. Defaults to the label followed by
39
+ * the option, lowercased: `'Breedte automatisch'`. Pass it for a language
40
+ * where that concatenation does not read.
41
+ */
42
+ autoAriaLabel?: string
43
+ /** Accessible name of the `fill` option. Defaults to `'Breedte vullen'`, built the same way. */
44
+ fillAriaLabel?: string
45
+ /** Accessible name of the `fixed` option. Defaults to `'Breedte vast'`, built the same way. */
46
+ fixedAriaLabel?: string
47
+ /** Accessible name of the unit selector, when more than one unit is offered. Defaults to `'Eenheid'`. */
48
+ unit?: string
49
+ /** How the px unit is written. Defaults to `'px'`. */
50
+ px?: string
51
+ /** How the percentage unit is written. Defaults to `'%'`. */
52
+ percent?: string
53
+ }
54
+
55
+ export interface NexxtEditorSizeProps {
56
+ /**
57
+ * Header text of the settings block, and the accessible name of the fixed
58
+ * field. Defaults to the Dutch `'Formaat'`; a consumer showing a width and a
59
+ * height passes `'Breedte'` and `'Hoogte'`.
60
+ */
61
+ label?: string
62
+ /**
63
+ * The modes offered, as a subset of `'auto'`, `'fill'` and `'fixed'`; they
64
+ * are always shown in that order. An image, which has nothing to fill,
65
+ * passes `['auto', 'fixed']`. Defaults to all three. A model whose mode is
66
+ * not offered shows no option selected.
67
+ */
68
+ modes?: EditorSizeMode[]
69
+ /** The units the fixed field offers. Defaults to `['px']`. */
70
+ units?: EditorDimensionUnit[]
71
+ /** Lowest fixed value per unit, merged over `EDITOR_DIMENSION_LIMITS`. */
72
+ min?: Partial<Record<EditorDimensionUnit, number>>
73
+ /** Highest fixed value per unit, merged over `EDITOR_DIMENSION_LIMITS`. */
74
+ max?: Partial<Record<EditorDimensionUnit, number>>
75
+ /**
76
+ * Copy of the options and the field. Defaults are Dutch and are merged per
77
+ * key, so overriding one keeps the defaults of the others. Copy is never
78
+ * translated inside the component.
79
+ */
80
+ messages?: EditorSizeMessages
81
+ }
82
+
83
+ const DEFAULT_MESSAGES = {
84
+ auto: 'Automatisch',
85
+ fill: 'Vullen',
86
+ fixed: 'Vast',
87
+ unit: 'Eenheid',
88
+ px: 'px',
89
+ percent: '%',
90
+ // The accessible names default to a string built from the label, not to copy of their own.
91
+ } satisfies Required<Omit<EditorSizeMessages, 'autoAriaLabel' | 'fillAriaLabel' | 'fixedAriaLabel'>>
92
+ </script>
93
+
94
+ <script lang="ts" setup>
95
+ import { computed, onBeforeUnmount, useTemplateRef, watch } from 'vue'
96
+ import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
97
+ import NexxtEditorDimensionField from '../../atoms/EditorDimensionField/EditorDimensionField.vue'
98
+ import NexxtToggleGroup from '../../../molecules/ToggleGroup/ToggleGroup.vue'
99
+ import type { NexxtToggleGroupOption } from '../../../molecules/ToggleGroup/ToggleGroup.vue'
100
+
101
+ defineOptions({ name: 'NexxtEditorSize' })
102
+
103
+ const {
104
+ label = 'Formaat',
105
+ modes,
106
+ units = ['px'],
107
+ min,
108
+ max,
109
+ messages,
110
+ } = defineProps<NexxtEditorSizeProps>()
111
+
112
+ const emit = defineEmits<{
113
+ /**
114
+ * One edit is finished: a mode was picked, or the fixed field committed (see
115
+ * `NexxtEditorDimensionField`). A consumer that folds model writes into one
116
+ * undo step closes it here.
117
+ */
118
+ commit: []
119
+ }>()
120
+
121
+ /**
122
+ * The size. Picking `'fixed'` without a `fixed` dimension leaves the field
123
+ * empty until a value is entered; seed `fixed` -- with the measured size, say
124
+ * -- to start from there instead.
125
+ */
126
+ const model = defineModel<EditorSizeValue>({ required: true })
127
+
128
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
129
+
130
+ const fieldEl = useTemplateRef<InstanceType<typeof NexxtEditorDimensionField>>('field')
131
+
132
+ // Two of these blocks usually sit together (width and height), and without
133
+ // the label in the name both would read out as the same three buttons.
134
+ const ariaLabelOf = (token: EditorSizeMode) =>
135
+ messages?.[`${token}AriaLabel`] ?? `${label} ${copy.value[token].toLocaleLowerCase()}`
136
+
137
+ const options = computed<NexxtToggleGroupOption[]>(() =>
138
+ EDITOR_SIZE_OPTIONS.filter(({ token }) => !modes || modes.includes(token)).map(({ token }) => ({
139
+ value: token,
140
+ label: copy.value[token],
141
+ ariaLabel: ariaLabelOf(token),
142
+ })),
143
+ )
144
+
145
+ // ToggleGroup types its model as the open `string` of its options; the only
146
+ // values it can emit are the tokens built above.
147
+ //
148
+ /**
149
+ * Set when the user picks `'fixed'`: the field takes focus once the model
150
+ * actually turns fixed. That may be later than the pick -- a consumer can
151
+ * debounce the model or route it through a store -- and until then the field
152
+ * is `inert` and cannot take focus.
153
+ */
154
+ let focusOnFixed = false
155
+
156
+ // The pick expires on the user's next press anywhere, so a pick the consumer
157
+ // never applied does not move focus on a later model change from outside.
158
+ const EXPIRE_ON = ['pointerdown', 'keydown'] as const
159
+
160
+ const expireFocus = () => {
161
+ focusOnFixed = false
162
+ EXPIRE_ON.forEach((type) => document.removeEventListener(type, expireFocus, true))
163
+ }
164
+
165
+ onBeforeUnmount(expireFocus)
166
+
167
+ // Only a pick in the group passes this setter -- a click, or Enter or Space on
168
+ // an option; the group has no roving focus, so arrow keys pick nothing. A mount
169
+ // in `'fixed'` or a model change from outside does not come through here.
170
+ const mode = computed({
171
+ get: () => model.value.mode,
172
+ set: (value: string) => {
173
+ if (value === model.value.mode) return
174
+
175
+ expireFocus()
176
+ if (value === 'fixed') {
177
+ focusOnFixed = true
178
+ // Added while the activating event is already past the document, so the
179
+ // press that picked `'fixed'` does not expire it.
180
+ EXPIRE_ON.forEach((type) => document.addEventListener(type, expireFocus, true))
181
+ }
182
+
183
+ model.value = { ...model.value, mode: value as EditorSizeMode }
184
+ emit('commit')
185
+ },
186
+ })
187
+
188
+ // The field never writes `null` -- it is not optional -- so there is always a
189
+ // dimension to store.
190
+ const fixed = computed({
191
+ get: () => model.value.fixed ?? null,
192
+ set: (value: EditorDimensionValue | null) => {
193
+ if (value) model.value = { ...model.value, fixed: value }
194
+ },
195
+ })
196
+
197
+ const isFixed = computed(() => model.value.mode === 'fixed')
198
+
199
+ // `:inert="false"` still renders the attribute, so `undefined` removes it.
200
+ const fieldInert = computed(() => (isFixed.value ? undefined : true))
201
+
202
+ // After the render that lifts `inert`, so the field can take focus. The first
203
+ // mode change after a pick settles it either way. No scrolling, or the browser
204
+ // would scroll the clipping box of the row that is still sliding open.
205
+ watch(
206
+ () => model.value.mode,
207
+ (next) => {
208
+ const focus = focusOnFixed && next === 'fixed'
209
+ expireFocus()
210
+ if (focus) fieldEl.value?.focus({ preventScroll: true })
211
+ },
212
+ { flush: 'post' },
213
+ )
214
+ </script>
215
+
216
+ <template>
217
+ <NexxtEditorSettingsBlock :label="label">
218
+ <NexxtToggleGroup v-model="mode" :options="options" />
219
+
220
+ <!--
221
+ The field slides open under the choice it belongs to, the same 0fr/1fr
222
+ grid as the second padding row, and stays mounted so it can animate.
223
+ `-mx-1` / `px-1` leave room for the focus ring inside the clipping box.
224
+ -->
225
+ <div
226
+ data-testid="editor-size-fixed-row"
227
+ :inert="fieldInert"
228
+ :class="[
229
+ 'grid transition-[grid-template-rows] duration-200 ease-out-strong motion-reduce:transition-none',
230
+ isFixed ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
231
+ ]"
232
+ >
233
+ <div
234
+ :class="[
235
+ '-mx-1 min-h-0 overflow-hidden transition-opacity duration-200 ease-out-strong motion-reduce:transition-none',
236
+ isFixed ? 'opacity-100' : 'opacity-0',
237
+ ]"
238
+ >
239
+ <div class="px-1 pt-2 pb-1">
240
+ <NexxtEditorDimensionField
241
+ ref="field"
242
+ v-model="fixed"
243
+ :label="label"
244
+ :units="units"
245
+ :min="min"
246
+ :max="max"
247
+ :messages="{ unit: copy.unit, px: copy.px, percent: copy.percent }"
248
+ @commit="emit('commit')"
249
+ />
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </NexxtEditorSettingsBlock>
254
+ </template>
@@ -0,0 +1,64 @@
1
+ <script lang="ts">
2
+ import type {
3
+ EditorDimensionFieldMessages,
4
+ EditorDimensionUnit,
5
+ } from '../../atoms/EditorDimensionField/EditorDimensionField.vue'
6
+
7
+ /** Overridable copy of the field. Every key is optional. */
8
+ export type EditorSizeLimitMessages = EditorDimensionFieldMessages
9
+
10
+ export interface NexxtEditorSizeLimitProps {
11
+ /** Visible label above the field, and its accessible name, as in `'Maximale breedte'`. */
12
+ label: string
13
+ /** The units the field offers. Defaults to `['px']`. */
14
+ units?: EditorDimensionUnit[]
15
+ /** Lowest value per unit, merged over `EDITOR_DIMENSION_LIMITS`. */
16
+ min?: Partial<Record<EditorDimensionUnit, number>>
17
+ /** Highest value per unit, merged over `EDITOR_DIMENSION_LIMITS`. */
18
+ max?: Partial<Record<EditorDimensionUnit, number>>
19
+ /**
20
+ * Copy of the placeholder and the units. Defaults are Dutch and are merged
21
+ * per key. Copy is never translated inside the component.
22
+ */
23
+ messages?: EditorSizeLimitMessages
24
+ }
25
+ </script>
26
+
27
+ <script lang="ts" setup>
28
+ import NexxtEditorDimensionField from '../../atoms/EditorDimensionField/EditorDimensionField.vue'
29
+ import type { EditorDimensionValue } from '../../atoms/EditorDimensionField/EditorDimensionField.vue'
30
+
31
+ defineOptions({ name: 'NexxtEditorSizeLimit' })
32
+
33
+ const { label, units = ['px'], min, max, messages } = defineProps<NexxtEditorSizeLimitProps>()
34
+
35
+ const emit = defineEmits<{
36
+ /** One edit is finished; see `NexxtEditorDimensionField`. */
37
+ commit: []
38
+ }>()
39
+
40
+ /** The limit, or `null` for none: emptying the field removes it. */
41
+ const model = defineModel<EditorDimensionValue | null>({ default: null })
42
+ </script>
43
+
44
+ <template>
45
+ <!--
46
+ No settings block of its own: a limit sits inside a block that holds the
47
+ sizes it limits, typically two side by side (maximum width, minimum height).
48
+ The label is a real one above the field, like a toggle group's label.
49
+ -->
50
+ <label class="flex min-w-0 flex-col gap-1">
51
+ <span class="small-normal text-gray-700">{{ label }}</span>
52
+
53
+ <NexxtEditorDimensionField
54
+ v-model="model"
55
+ :label="label"
56
+ :units="units"
57
+ :min="min"
58
+ :max="max"
59
+ :messages="messages"
60
+ optional
61
+ @commit="emit('commit')"
62
+ />
63
+ </label>
64
+ </template>
@@ -72,6 +72,7 @@
72
72
  "NexxtTimelineTrack": "components/organisms/TimelineTrack/TimelineTrack.vue",
73
73
  "NexxtToaster": "components/organisms/Toaster/Toaster.vue",
74
74
  "NexxtVideoPlayer": "components/organisms/VideoPlayer/VideoPlayer.vue",
75
+ "NexxtEditorDimensionField": "components/editor/atoms/EditorDimensionField/EditorDimensionField.vue",
75
76
  "NexxtEditorSettingsBlock": "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
76
77
  "NexxtEditorAlignItems": "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
77
78
  "NexxtEditorBackgroundSize": "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
@@ -87,5 +88,7 @@
87
88
  "NexxtEditorMaxWidth": "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
88
89
  "NexxtEditorPadding": "components/editor/molecules/EditorPadding/EditorPadding.vue",
89
90
  "NexxtEditorRadius": "components/editor/molecules/EditorRadius/EditorRadius.vue",
91
+ "NexxtEditorSize": "components/editor/molecules/EditorSize/EditorSize.vue",
92
+ "NexxtEditorSizeLimit": "components/editor/molecules/EditorSizeLimit/EditorSizeLimit.vue",
90
93
  "NexxtEditorTextAlign": "components/editor/molecules/EditorTextAlign/EditorTextAlign.vue"
91
94
  }
package/src/index.ts CHANGED
@@ -125,6 +125,19 @@ export type {
125
125
  EditorPaddingMessages,
126
126
  EditorPaddingStep,
127
127
  } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
128
+ export { EDITOR_DIMENSION_LIMITS } from './components/editor/atoms/EditorDimensionField/EditorDimensionField.vue'
129
+ export type {
130
+ EditorDimensionFieldMessages,
131
+ EditorDimensionUnit,
132
+ EditorDimensionValue,
133
+ } from './components/editor/atoms/EditorDimensionField/EditorDimensionField.vue'
134
+ export { EDITOR_SIZE_OPTIONS } from './components/editor/molecules/EditorSize/EditorSize.vue'
135
+ export type {
136
+ EditorSizeMessages,
137
+ EditorSizeMode,
138
+ EditorSizeValue,
139
+ } from './components/editor/molecules/EditorSize/EditorSize.vue'
140
+ export type { EditorSizeLimitMessages } from './components/editor/molecules/EditorSizeLimit/EditorSizeLimit.vue'
128
141
  export type {
129
142
  NexxtTemplateCardContentFeature,
130
143
  NexxtTemplateCardProps,
@@ -152,6 +165,7 @@ export { default as NexxtEditorAlignItems } from './components/editor/molecules/
152
165
  export { default as NexxtEditorBackgroundSize } from './components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue'
153
166
  export { default as NexxtEditorBorder } from './components/editor/molecules/EditorBorder/EditorBorder.vue'
154
167
  export { default as NexxtEditorColor } from './components/editor/molecules/EditorColor/EditorColor.vue'
168
+ export { default as NexxtEditorDimensionField } from './components/editor/atoms/EditorDimensionField/EditorDimensionField.vue'
155
169
  export { default as NexxtEditorFlexDirection } from './components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue'
156
170
  export { default as NexxtEditorFontSize } from './components/editor/molecules/EditorFontSize/EditorFontSize.vue'
157
171
  export { default as NexxtEditorGap } from './components/editor/molecules/EditorGap/EditorGap.vue'
@@ -163,6 +177,8 @@ export { default as NexxtEditorMaxWidth } from './components/editor/molecules/Ed
163
177
  export { default as NexxtEditorPadding } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
164
178
  export { default as NexxtEditorRadius } from './components/editor/molecules/EditorRadius/EditorRadius.vue'
165
179
  export { default as NexxtEditorSettingsBlock } from './components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
180
+ export { default as NexxtEditorSize } from './components/editor/molecules/EditorSize/EditorSize.vue'
181
+ export { default as NexxtEditorSizeLimit } from './components/editor/molecules/EditorSizeLimit/EditorSizeLimit.vue'
166
182
  export { default as NexxtEditorTextAlign } from './components/editor/molecules/EditorTextAlign/EditorTextAlign.vue'
167
183
  export { default as NexxtEnergyLabel } from './components/atoms/EnergyLabel/EnergyLabel.vue'
168
184
  export { default as NexxtFloatingPanel } from './components/atoms/FloatingPanel/FloatingPanel.vue'