@nexxtmove/ui 1.19.0 → 1.20.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
@@ -68,7 +68,8 @@ var s = {
68
68
  NexxtEditorAlignItems: "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
69
69
  NexxtEditorFlexDirection: "components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue",
70
70
  NexxtEditorGap: "components/editor/molecules/EditorGap/EditorGap.vue",
71
- NexxtEditorJustifyContent: "components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue"
71
+ NexxtEditorJustifyContent: "components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue",
72
+ NexxtEditorPadding: "components/editor/molecules/EditorPadding/EditorPadding.vue"
72
73
  }, c = ["@nexxtmove/ui/ui.css", "@nexxtmove/ui/dist/ui.css"], l = (e, t) => e.some((e) => e === t || c.includes(e)), u = i({
73
74
  meta: {
74
75
  name: "@nexxtmove/ui",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.19.0",
4
+ "version": "1.20.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -0,0 +1,310 @@
1
+ <script lang="ts">
2
+ import type { PaddingIconSide } from './_PaddingIcon.vue'
3
+
4
+ /**
5
+ * The steps a field can land on, as the numeric part of the Tailwind class.
6
+ * 13 and 15 are missing on purpose: the theme defines `--spacing-0` through
7
+ * `--spacing-12` and then only 14 and 16, so offering every integer would
8
+ * produce classes that have no token behind them.
9
+ */
10
+ const PADDING_STEPS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16] as const
11
+
12
+ /** Highest step, used as the `max` of every field and as the clamp ceiling. */
13
+ const MAX_STEP = PADDING_STEPS[PADDING_STEPS.length - 1]
14
+
15
+ const ALL_SIDES: PaddingIconSide[] = ['top', 'right', 'bottom', 'left']
16
+
17
+ /** Padding per side, as the numeric part of the Tailwind class. */
18
+ type PaddingSides = Record<PaddingIconSide, number>
19
+
20
+ /** Overridable copy of the field and toggle names. Every key is optional. */
21
+ export interface EditorPaddingMessages {
22
+ /** Accessible name of the collapsed left/right field. Defaults to `'Horizontaal'`. */
23
+ horizontal?: string
24
+ /** Accessible name of the collapsed top/bottom field. Defaults to `'Verticaal'`. */
25
+ vertical?: string
26
+ /** Accessible name of the top field. Defaults to `'Boven'`. */
27
+ top?: string
28
+ /** Accessible name of the right field. Defaults to `'Rechts'`. */
29
+ right?: string
30
+ /** Accessible name of the bottom field. Defaults to `'Onder'`. */
31
+ bottom?: string
32
+ /** Accessible name of the left field. Defaults to `'Links'`. */
33
+ left?: string
34
+ /**
35
+ * Accessible name *and* tooltip of the button that shows the four separate
36
+ * fields. Defaults to `'Padding per zijde'`.
37
+ */
38
+ toggle?: string
39
+ }
40
+
41
+ export interface NexxtEditorPaddingProps {
42
+ /**
43
+ * Header text of the settings block. Defaults to the Dutch `'Padding'`; the
44
+ * library has no i18n layer, so a consumer in another language passes its
45
+ * own copy in.
46
+ */
47
+ label?: string
48
+ /**
49
+ * Accessible names of the fields and the toggle. Defaults are Dutch and are
50
+ * merged per key, so overriding one keeps the defaults of the others. Copy
51
+ * is never translated inside the component.
52
+ */
53
+ messages?: EditorPaddingMessages
54
+ }
55
+
56
+ const DEFAULT_MESSAGES = {
57
+ horizontal: 'Horizontaal',
58
+ vertical: 'Verticaal',
59
+ top: 'Boven',
60
+ right: 'Rechts',
61
+ bottom: 'Onder',
62
+ left: 'Links',
63
+ toggle: 'Padding per zijde',
64
+ } satisfies Required<EditorPaddingMessages>
65
+
66
+ /** Which sides a `p*-` prefix writes to. */
67
+ const PREFIX_SIDES: Record<string, PaddingIconSide[]> = {
68
+ '': ALL_SIDES,
69
+ x: ['left', 'right'],
70
+ y: ['top', 'bottom'],
71
+ t: ['top'],
72
+ r: ['right'],
73
+ b: ['bottom'],
74
+ l: ['left'],
75
+ }
76
+
77
+ const TOKEN = /^p([xytrbl]?)-(\d+)$/
78
+
79
+ /**
80
+ * Read the four sides out of a Tailwind class string. Anything that is not a
81
+ * padding utility on the step list is ignored, and a later token wins over an
82
+ * earlier one -- the same thing Tailwind's own cascade does for `p-4 pt-2`.
83
+ */
84
+ const parse = (value: string): PaddingSides => {
85
+ const sides: PaddingSides = { top: 0, right: 0, bottom: 0, left: 0 }
86
+
87
+ for (const token of value.split(/\s+/)) {
88
+ const match = TOKEN.exec(token)
89
+ if (!match) continue
90
+
91
+ const step = Number(match[2])
92
+ if (!PADDING_STEPS.includes(step as (typeof PADDING_STEPS)[number])) continue
93
+
94
+ for (const side of PREFIX_SIDES[match[1]]) sides[side] = step
95
+ }
96
+
97
+ return sides
98
+ }
99
+
100
+ /** Write the four sides back as the shortest class string that expresses them. */
101
+ const serialize = ({ top, right, bottom, left }: PaddingSides): string => {
102
+ if (top === right && right === bottom && bottom === left) return `p-${top}`
103
+ if (top === bottom && left === right) return `px-${left} py-${top}`
104
+ return `pt-${top} pr-${right} pb-${bottom} pl-${left}`
105
+ }
106
+
107
+ /**
108
+ * Clamp to 0-16 and snap to the nearest step. A tie goes to the lower step, so
109
+ * 13 becomes 12 and 15 becomes 14; that way typing a digit never jumps the
110
+ * value further than the next step up.
111
+ */
112
+ const snap = (value: number): number => {
113
+ const clamped = Math.min(Math.max(value, 0), MAX_STEP)
114
+
115
+ return PADDING_STEPS.reduce((best, step) =>
116
+ Math.abs(step - clamped) < Math.abs(best - clamped) ? step : best,
117
+ )
118
+ }
119
+ </script>
120
+
121
+ <script lang="ts" setup>
122
+ import { computed, ref, watch } from 'vue'
123
+ import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
124
+ import NexxtTooltip from '../../../atoms/Tooltip/Tooltip.vue'
125
+ import PaddingField from './_PaddingField.vue'
126
+ import PaddingIcon from './_PaddingIcon.vue'
127
+
128
+ defineOptions({ name: 'NexxtEditorPadding' })
129
+
130
+ const { label = 'Padding', messages } = defineProps<NexxtEditorPaddingProps>()
131
+
132
+ /** The padding, as the Tailwind class string to apply. */
133
+ const model = defineModel<string>({ required: true })
134
+
135
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
136
+
137
+ const sides = ref<PaddingSides>(parse(model.value))
138
+
139
+ // The model is the source of truth, so a change from outside re-reads it. The
140
+ // open state is deliberately left alone: collapsing is a view choice of the
141
+ // user, and a re-parse mid-edit should not fold the four fields away.
142
+ watch(model, (value) => {
143
+ sides.value = parse(value)
144
+ })
145
+
146
+ /**
147
+ * Whether the four separate fields are shown. Opens on mount when the value
148
+ * cannot be expressed with two fields; from then on the button owns it.
149
+ */
150
+ const expanded = ref(
151
+ sides.value.top !== sides.value.bottom || sides.value.left !== sides.value.right,
152
+ )
153
+
154
+ interface PaddingField {
155
+ key: string
156
+ /** Sides this field writes to. */
157
+ targets: PaddingIconSide[]
158
+ /** Side this field reads from. */
159
+ source: PaddingIconSide
160
+ label: string
161
+ }
162
+
163
+ const singleSideField = (side: PaddingIconSide): PaddingField => ({
164
+ key: side,
165
+ targets: [side],
166
+ source: side,
167
+ label: copy.value[side],
168
+ })
169
+
170
+ /**
171
+ * The row that is always visible. Its two fields swap meaning the instant the
172
+ * toggle flips -- only the second row animates, so the block never jumps.
173
+ */
174
+ const primaryFields = computed<PaddingField[]>(() =>
175
+ expanded.value
176
+ ? [singleSideField('top'), singleSideField('right')]
177
+ : [
178
+ {
179
+ key: 'horizontal',
180
+ targets: ['left', 'right'],
181
+ source: 'left',
182
+ label: copy.value.horizontal,
183
+ },
184
+ { key: 'vertical', targets: ['top', 'bottom'], source: 'top', label: copy.value.vertical },
185
+ ],
186
+ )
187
+
188
+ /** The row that slides open. Stays mounted so its height can be animated. */
189
+ const secondaryFields = computed<PaddingField[]>(() => [
190
+ singleSideField('bottom'),
191
+ singleSideField('left'),
192
+ ])
193
+
194
+ // Vue only drops an attribute on `false` for a fixed list of boolean
195
+ // attributes, and `inert` is not on that list: `:inert="false"` renders
196
+ // `inert="false"`, which a browser still treats as inert. `undefined` is the
197
+ // only value that removes the attribute -- and with it the two hidden fields
198
+ // from the tab order. Same trick as EditorSettingsBlock.
199
+ const secondaryInert = computed(() => (expanded.value ? undefined : true))
200
+
201
+ // An empty field is a 0, and a 0 is shown as an empty field so the placeholder
202
+ // carries it. Typing "0" therefore leaves the bound value unchanged, which is
203
+ // what keeps Vue from wiping the character out from under the caret.
204
+ const display = (side: PaddingIconSide) =>
205
+ sides.value[side] === 0 ? '' : String(sides.value[side])
206
+
207
+ // Only plain digits count. `Number.parseInt` would read "1e2" as 1 and "12abc"
208
+ // as 12, which silently turns a typo into a value the user did not ask for.
209
+ const DIGITS = /^\d+$/
210
+
211
+ const onInput = (field: PaddingField, typed: string) => {
212
+ const raw = typed.trim()
213
+ const step = DIGITS.test(raw) ? snap(Number(raw)) : 0
214
+
215
+ const next = { ...sides.value }
216
+ for (const side of field.targets) next[side] = step
217
+ sides.value = next
218
+
219
+ model.value = serialize(next)
220
+ }
221
+ </script>
222
+
223
+ <template>
224
+ <NexxtEditorSettingsBlock :label="label">
225
+ <!--
226
+ Both rows share one column template, and the third column is a fixed
227
+ 2.5rem instead of `auto`: the second row has no toggle, so an `auto`
228
+ column would collapse there and its fields would come out wider than the
229
+ ones above them.
230
+ -->
231
+ <div class="grid grid-cols-[1fr_1fr_2.5rem] gap-2">
232
+ <PaddingField
233
+ v-for="field in primaryFields"
234
+ :key="field.key"
235
+ :sides="field.targets"
236
+ :label="field.label"
237
+ :value="display(field.source)"
238
+ :max="MAX_STEP"
239
+ @input="onInput(field, $event)"
240
+ />
241
+
242
+ <!--
243
+ Tooltip renders no element of its own -- its trigger is `as-child` --
244
+ so the button itself stays the third grid item and the columns are
245
+ unaffected. The tooltip repeats the accessible name rather than adding
246
+ copy: the icon alone does not say what the button does.
247
+ -->
248
+ <NexxtTooltip disable-closing-trigger>
249
+ <button
250
+ type="button"
251
+ :aria-label="copy.toggle"
252
+ :aria-pressed="expanded"
253
+ :class="[
254
+ 'flex size-10 cursor-pointer items-center justify-center rounded-lg ring transition-colors duration-150 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-cornflower-blue-500 focus-visible:ring-offset-2 motion-reduce:transition-none',
255
+ expanded
256
+ ? 'bg-cornflower-blue-50 text-cornflower-blue-600 ring-cornflower-blue-500'
257
+ : 'text-gray-700 ring-gray-200 hover:text-gray-900',
258
+ ]"
259
+ @click="expanded = !expanded"
260
+ >
261
+ <PaddingIcon :sides="ALL_SIDES" />
262
+ </button>
263
+ <template #tooltip>
264
+ <span class="small-normal text-gray-900">{{ copy.toggle }}</span>
265
+ </template>
266
+ </NexxtTooltip>
267
+ </div>
268
+
269
+ <!--
270
+ Only the second row animates: the first row keeps its place and swaps its
271
+ contents, so opening the four fields grows the block instead of jumping
272
+ it. The row stays mounted -- a 0fr grid row is what collapses it -- and
273
+ `inert` keeps the two hidden fields out of the tab order meanwhile.
274
+ -->
275
+ <div
276
+ data-testid="editor-padding-secondary-row"
277
+ :inert="secondaryInert"
278
+ :class="[
279
+ 'grid transition-[grid-template-rows] duration-200 ease-out-strong motion-reduce:transition-none',
280
+ expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
281
+ ]"
282
+ >
283
+ <!--
284
+ `overflow-hidden` is what makes the 0fr collapse clip, but it would also
285
+ clip the focus ring of the fields inside. `-mx-1` widens the clipping
286
+ box by 4px on each side and the `px-1` below pushes the content back, so
287
+ the ring has room while the columns stay aligned with the row above.
288
+ -->
289
+ <div
290
+ :class="[
291
+ '-mx-1 min-h-0 overflow-hidden transition-opacity duration-200 ease-out-strong motion-reduce:transition-none',
292
+ expanded ? 'opacity-100' : 'opacity-0',
293
+ ]"
294
+ >
295
+ <!-- Spacing sits inside the clipped box so nothing is left over at 0fr. -->
296
+ <div class="grid grid-cols-[1fr_1fr_2.5rem] gap-2 px-1 pt-2 pb-1">
297
+ <PaddingField
298
+ v-for="field in secondaryFields"
299
+ :key="field.key"
300
+ :sides="field.targets"
301
+ :label="field.label"
302
+ :value="display(field.source)"
303
+ :max="MAX_STEP"
304
+ @input="onInput(field, $event)"
305
+ />
306
+ </div>
307
+ </div>
308
+ </div>
309
+ </NexxtEditorSettingsBlock>
310
+ </template>
@@ -0,0 +1,56 @@
1
+ <script lang="ts" setup>
2
+ import PaddingIcon from './_PaddingIcon.vue'
3
+ import type { PaddingIconSide } from './_PaddingIcon.vue'
4
+
5
+ export interface NexxtEditorPaddingFieldProps {
6
+ /** Sides the icon marks, which is also what this field edits. */
7
+ sides: PaddingIconSide[]
8
+ /** Accessible name of the input; the settings block label is not a `<label for>`. */
9
+ label: string
10
+ /** Value as shown, already normalised. An empty string falls back to the `0` placeholder. */
11
+ value: string
12
+ /** Highest value the input accepts. */
13
+ max: number
14
+ }
15
+
16
+ defineOptions({ name: 'NexxtEditorPaddingField' })
17
+
18
+ defineProps<NexxtEditorPaddingFieldProps>()
19
+
20
+ /**
21
+ * The raw string the user typed, on every keystroke rather than on change.
22
+ * Parsing and snapping belong to the parent, which owns the class string.
23
+ */
24
+ const emit = defineEmits<{ input: [value: string] }>()
25
+
26
+ const onInput = (event: Event) => emit('input', (event.target as HTMLInputElement).value)
27
+ </script>
28
+
29
+ <template>
30
+ <!--
31
+ Visually a NexxtInputField, rebuilt here: that component forces `min-w-60`
32
+ on its input, which blows up this two-column grid, and renders spin buttons
33
+ for `type="number"` that make no sense against a step list.
34
+ -->
35
+ <div
36
+ class="flex items-center gap-2 overflow-hidden rounded-lg pl-3 ring ring-gray-200 transition-colors duration-150 ease-out focus-within:ring-cornflower-blue-500 hover:ring-gray-300 motion-reduce:transition-none"
37
+ >
38
+ <PaddingIcon :sides="sides" class="text-gray-900" />
39
+
40
+ <!--
41
+ `type="number"` for the numeric keypad on mobile; the spinners are hidden
42
+ because the value snaps to a step list, not to a fixed interval.
43
+ -->
44
+ <input
45
+ type="number"
46
+ inputmode="numeric"
47
+ min="0"
48
+ :max="max"
49
+ placeholder="0"
50
+ :aria-label="label"
51
+ :value="value"
52
+ class="h-10 w-full bg-transparent pr-3 small-normal text-gray-900 placeholder-gray-700 focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
53
+ @input="onInput"
54
+ />
55
+ </div>
56
+ </template>
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ /** The four edges of the box, in CSS order. */
3
+ export type PaddingIconSide = 'top' | 'right' | 'bottom' | 'left'
4
+
5
+ export interface NexxtEditorPaddingIconProps {
6
+ /**
7
+ * Edges that are lit, so one icon covers every field: `['left', 'right']`
8
+ * reads as horizontal padding, `['top', 'bottom']` as vertical, a single
9
+ * side as that side, and all four as padding in general. The other lines are
10
+ * drawn as well, but transparent.
11
+ */
12
+ sides: PaddingIconSide[]
13
+ }
14
+
15
+ /** Outer box: inset by 1 so the 1.25 stroke stays inside the 16×16 viewBox. */
16
+ const EDGE = 1
17
+ /** Inner lines sit at 4, three units inside the outer box path at 1. */
18
+ const INNER = 4
19
+
20
+ /**
21
+ * Where an inner line starts and ends along its own axis. With all four sides
22
+ * lit the lines would touch in the corners, so they are pulled in on both ends;
23
+ * by 2, not by 1, because `stroke-linecap="round"` gives every line back half a
24
+ * stroke width at each end -- a 1-unit inset nets out at 0.375 and leaves no
25
+ * visible corner gap at 16 px. With fewer sides the longer line reads better.
26
+ */
27
+ const SPAN = { long: [INNER, 16 - INNER], short: [INNER + 2, 16 - INNER - 2] } as const
28
+
29
+ /** Drawn in this order, so the four lines keep their identity across a swap. */
30
+ const ALL_SIDES: PaddingIconSide[] = ['top', 'right', 'bottom', 'left']
31
+ </script>
32
+
33
+ <script lang="ts" setup>
34
+ import { computed } from 'vue'
35
+
36
+ defineOptions({ name: 'NexxtEditorPaddingIcon' })
37
+
38
+ const { sides } = defineProps<NexxtEditorPaddingIconProps>()
39
+
40
+ const geometry = (side: PaddingIconSide, from: number, to: number) => {
41
+ if (side === 'top') return { x1: from, y1: INNER, x2: to, y2: INNER }
42
+ if (side === 'bottom') return { x1: from, y1: 16 - INNER, x2: to, y2: 16 - INNER }
43
+ if (side === 'left') return { x1: INNER, y1: from, x2: INNER, y2: to }
44
+ return { x1: 16 - INNER, y1: from, x2: 16 - INNER, y2: to }
45
+ }
46
+
47
+ // All four lines are always rendered and an inactive one is only faded out.
48
+ // Swapping the icon of a field (horizontal -> top, say) then animates the
49
+ // opacity instead of replacing one drawing with another in a single frame.
50
+ const lines = computed(() => {
51
+ const [from, to] = sides.length === 4 ? SPAN.short : SPAN.long
52
+
53
+ return ALL_SIDES.map((side) => ({
54
+ side,
55
+ active: sides.includes(side),
56
+ ...geometry(side, from, to),
57
+ }))
58
+ })
59
+ </script>
60
+
61
+ <template>
62
+ <svg
63
+ class="size-4 shrink-0"
64
+ viewBox="0 0 16 16"
65
+ fill="none"
66
+ stroke="currentColor"
67
+ stroke-width="1.25"
68
+ stroke-linecap="round"
69
+ aria-hidden="true"
70
+ >
71
+ <rect :x="EDGE" :y="EDGE" :width="16 - EDGE * 2" :height="16 - EDGE * 2" rx="1.5" />
72
+ <line
73
+ v-for="line in lines"
74
+ :key="line.side"
75
+ :x1="line.x1"
76
+ :y1="line.y1"
77
+ :x2="line.x2"
78
+ :y2="line.y2"
79
+ :class="[
80
+ 'transition-opacity duration-150 ease-out-strong motion-reduce:transition-none',
81
+ line.active ? 'opacity-100' : 'opacity-0',
82
+ ]"
83
+ />
84
+ </svg>
85
+ </template>
@@ -65,5 +65,6 @@
65
65
  "NexxtEditorAlignItems": "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
66
66
  "NexxtEditorFlexDirection": "components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue",
67
67
  "NexxtEditorGap": "components/editor/molecules/EditorGap/EditorGap.vue",
68
- "NexxtEditorJustifyContent": "components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue"
68
+ "NexxtEditorJustifyContent": "components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue",
69
+ "NexxtEditorPadding": "components/editor/molecules/EditorPadding/EditorPadding.vue"
69
70
  }
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export type {
29
29
  EditorJustifyContentMessages,
30
30
  EditorJustifyContentValue,
31
31
  } from './components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue'
32
+ export type { EditorPaddingMessages } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
32
33
  export type {
33
34
  NexxtTemplateCardContentFeature,
34
35
  NexxtTemplateCardProps,
@@ -56,6 +57,7 @@ export { default as NexxtEditorAlignItems } from './components/editor/molecules/
56
57
  export { default as NexxtEditorFlexDirection } from './components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue'
57
58
  export { default as NexxtEditorGap } from './components/editor/molecules/EditorGap/EditorGap.vue'
58
59
  export { default as NexxtEditorJustifyContent } from './components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue'
60
+ export { default as NexxtEditorPadding } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
59
61
  export { default as NexxtEditorSettingsBlock } from './components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
60
62
  export { default as NexxtEnergyLabel } from './components/atoms/EnergyLabel/EnergyLabel.vue'
61
63
  export { default as NexxtFloatingPanel } from './components/atoms/FloatingPanel/FloatingPanel.vue'
@@ -42,6 +42,11 @@
42
42
  (app/assets/css/main.css); the app drops its copies once it adopts the
43
43
  version that ships these. */
44
44
  @theme {
45
+ /* Strong ease-out (`ease-out-strong`): starts fast and settles slowly, so a
46
+ panel that grows or shrinks reads as one motion instead of a jump. Used by
47
+ NexxtEditorPadding; Tailwind's own `ease-out` is too shallow at 200ms. */
48
+ --ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
49
+
45
50
  --spacing-8xl: 100rem;
46
51
  --spacing-full-width: 116rem; /* 1920px (full hd) minus the sidebar: max app width */
47
52
  }