@nexxtmove/ui 1.19.0 → 1.21.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,9 @@ 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
+ NexxtEditorRadius: "components/editor/molecules/EditorRadius/EditorRadius.vue",
73
+ NexxtEditorPadding: "components/editor/molecules/EditorPadding/EditorPadding.vue"
72
74
  }, c = ["@nexxtmove/ui/ui.css", "@nexxtmove/ui/dist/ui.css"], l = (e, t) => e.some((e) => e === t || c.includes(e)), u = i({
73
75
  meta: {
74
76
  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.21.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>
@@ -0,0 +1,137 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The steps the slider can land on: the radius scale the library actually has,
4
+ * which is the Figma theme tokens plus the Tailwind defaults that sit on top of
5
+ * them -- the theme adds no `--radius-*: initial`, so `rounded-2xl` through
6
+ * `rounded-4xl` and `rounded-full` resolve to Tailwind's own values. The px
7
+ * numbers mirror those values (rem × 16). Known limitation: the theme file is
8
+ * overwritten wholesale by the Figma token sync, and a sync that changes a
9
+ * radius does not reach this list -- it has to be updated by hand.
10
+ *
11
+ * `full` is last and carries no px: it is a pill, not a size on the scale, so
12
+ * the readout names it instead of measuring it.
13
+ */
14
+ const RADIUS_STEPS = [
15
+ { token: 'none', px: 0 },
16
+ { token: 'xs', px: 2 },
17
+ { token: 'sm', px: 4 },
18
+ { token: 'md', px: 6 },
19
+ { token: 'lg', px: 8 },
20
+ { token: 'xl', px: 12 },
21
+ { token: '2xl', px: 16 },
22
+ { token: '3xl', px: 24 },
23
+ { token: '4xl', px: 32 },
24
+ { token: 'full', px: null },
25
+ ] as const
26
+
27
+ /**
28
+ * The radius as the editor needs it: the Tailwind class itself, so the consumer
29
+ * can put the model value straight on the element it styles.
30
+ */
31
+ export type EditorRadiusValue = `rounded-${(typeof RADIUS_STEPS)[number]['token']}`
32
+
33
+ /** Overridable copy of the value readout. Every key is optional. */
34
+ export interface EditorRadiusMessages {
35
+ /** Unit shown behind the value. Defaults to `'px'`. */
36
+ unit?: string
37
+ /**
38
+ * Readout of the last step, `rounded-full`, which has no px size. Defaults to
39
+ * the Dutch `'Rond'`.
40
+ */
41
+ full?: string
42
+ }
43
+
44
+ export interface NexxtEditorRadiusProps {
45
+ /**
46
+ * Header text of the settings block. Defaults to the Dutch
47
+ * `'Hoekafronding'`; the library has no i18n layer, so a consumer in another
48
+ * language passes its own copy in.
49
+ */
50
+ label?: string
51
+ /**
52
+ * Copy of the value readout. Merged per key, so overriding one key keeps the
53
+ * defaults of the others. Copy is never translated inside the component.
54
+ */
55
+ messages?: EditorRadiusMessages
56
+ }
57
+
58
+ const DEFAULT_MESSAGES = {
59
+ unit: 'px',
60
+ full: 'Rond',
61
+ } satisfies Required<EditorRadiusMessages>
62
+ </script>
63
+
64
+ <script lang="ts" setup>
65
+ import { computed } from 'vue'
66
+ import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
67
+ import NexxtSlider from '../../../atoms/Slider/Slider.vue'
68
+
69
+ defineOptions({ name: 'NexxtEditorRadius' })
70
+
71
+ const { label = 'Hoekafronding', messages } = defineProps<NexxtEditorRadiusProps>()
72
+
73
+ /** The selected radius, as the Tailwind class to apply. */
74
+ const model = defineModel<EditorRadiusValue>({ required: true })
75
+
76
+ const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
77
+
78
+ const maxIndex = RADIUS_STEPS.length - 1
79
+
80
+ /**
81
+ * Position of the current value on the step list. A value that is not on the
82
+ * list -- a stored `rounded-5xl` from before this component, say -- falls back
83
+ * to the first step instead of leaving the thumb off the track; the first
84
+ * change writes a valid class back.
85
+ */
86
+ const stepIndex = computed(() =>
87
+ Math.max(
88
+ 0,
89
+ RADIUS_STEPS.findIndex(({ token }) => `rounded-${token}` === model.value),
90
+ ),
91
+ )
92
+
93
+ // The slider moves over the index, not over the px value, so its model is
94
+ // translated both ways here.
95
+ const sliderValue = computed({
96
+ get: () => [stepIndex.value],
97
+ set: ([index]) => {
98
+ model.value = `rounded-${RADIUS_STEPS[index].token}`
99
+ },
100
+ })
101
+
102
+ const displayValue = computed(() => {
103
+ const { px } = RADIUS_STEPS[stepIndex.value]
104
+
105
+ return px === null ? copy.value.full : `${px} ${copy.value.unit}`
106
+ })
107
+ </script>
108
+
109
+ <template>
110
+ <NexxtEditorSettingsBlock :label="label">
111
+ <div class="flex items-center gap-4">
112
+ <!--
113
+ Slider's own `showValue` prints its model, which here is the step index
114
+ rather than the radius, so the readout is rendered next to it instead.
115
+ The thumb has the same problem in `aria-valuenow`, which
116
+ `aria-valuetext` overrides with the string the readout shows. The block
117
+ label is a <p>, not a <label>, so the name goes on the thumb explicitly.
118
+ -->
119
+ <NexxtSlider
120
+ v-model="sliderValue"
121
+ :min="0"
122
+ :max="maxIndex"
123
+ :step="1"
124
+ :aria-label="label"
125
+ :aria-valuetext="displayValue"
126
+ />
127
+
128
+ <!--
129
+ gray-700, not the gray-500 Slider uses for its own readout: axe puts
130
+ gray-500 on white at 1.84:1, well under the 4.5:1 it needs at this size.
131
+ -->
132
+ <span class="min-w-12 shrink-0 text-right small-normal whitespace-nowrap text-gray-700">
133
+ {{ displayValue }}
134
+ </span>
135
+ </div>
136
+ </NexxtEditorSettingsBlock>
137
+ </template>
@@ -65,5 +65,7 @@
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
+ "NexxtEditorRadius": "components/editor/molecules/EditorRadius/EditorRadius.vue",
70
+ "NexxtEditorPadding": "components/editor/molecules/EditorPadding/EditorPadding.vue"
69
71
  }
package/src/index.ts CHANGED
@@ -25,10 +25,15 @@ export type {
25
25
  EditorGapMessages,
26
26
  EditorGapValue,
27
27
  } from './components/editor/molecules/EditorGap/EditorGap.vue'
28
+ export type {
29
+ EditorRadiusMessages,
30
+ EditorRadiusValue,
31
+ } from './components/editor/molecules/EditorRadius/EditorRadius.vue'
28
32
  export type {
29
33
  EditorJustifyContentMessages,
30
34
  EditorJustifyContentValue,
31
35
  } from './components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue'
36
+ export type { EditorPaddingMessages } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
32
37
  export type {
33
38
  NexxtTemplateCardContentFeature,
34
39
  NexxtTemplateCardProps,
@@ -56,6 +61,8 @@ export { default as NexxtEditorAlignItems } from './components/editor/molecules/
56
61
  export { default as NexxtEditorFlexDirection } from './components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue'
57
62
  export { default as NexxtEditorGap } from './components/editor/molecules/EditorGap/EditorGap.vue'
58
63
  export { default as NexxtEditorJustifyContent } from './components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue'
64
+ export { default as NexxtEditorRadius } from './components/editor/molecules/EditorRadius/EditorRadius.vue'
65
+ export { default as NexxtEditorPadding } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
59
66
  export { default as NexxtEditorSettingsBlock } from './components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
60
67
  export { default as NexxtEnergyLabel } from './components/atoms/EnergyLabel/EnergyLabel.vue'
61
68
  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
  }