@nexxtmove/ui 1.27.0 → 1.29.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/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue.d.ts +99 -0
- package/dist/components/editor/molecules/EditorPadding/EditorPadding.vue.d.ts +6 -0
- package/dist/components/editor/molecules/EditorPadding/_PaddingField.vue.d.ts +10 -8
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1238 -1081
- package/dist/nuxt.js +1 -0
- package/dist/ui.css +1 -1
- package/package.json +1 -1
- package/src/components/atoms/Switch/Switch.vue +6 -1
- package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue +146 -0
- package/src/components/editor/molecules/EditorPadding/EditorPadding.vue +74 -30
- package/src/components/editor/molecules/EditorPadding/_PaddingField.vue +208 -21
- package/src/components.json +1 -0
- package/src/index.ts +6 -0
package/dist/nuxt.js
CHANGED
|
@@ -76,6 +76,7 @@ var s = {
|
|
|
76
76
|
NexxtEditorGap: "components/editor/molecules/EditorGap/EditorGap.vue",
|
|
77
77
|
NexxtEditorHeadingType: "components/editor/molecules/EditorHeadingType/EditorHeadingType.vue",
|
|
78
78
|
NexxtEditorJustifyContent: "components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue",
|
|
79
|
+
NexxtEditorMaxWidth: "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
|
|
79
80
|
NexxtEditorPadding: "components/editor/molecules/EditorPadding/EditorPadding.vue",
|
|
80
81
|
NexxtEditorRadius: "components/editor/molecules/EditorRadius/EditorRadius.vue",
|
|
81
82
|
NexxtEditorTextAlign: "components/editor/molecules/EditorTextAlign/EditorTextAlign.vue"
|
package/dist/ui.css
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
rect[data-v-0a5d9b21]{animation-duration:4s;animation-timing-function:cubic-bezier(.77,0,.175,1);animation-iteration-count:infinite}rect[data-v-0a5d9b21]:first-child{animation-name:load-1-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(2){animation-name:load-2-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(3){animation-name:load-3-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(4){animation-name:load-4-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(5){animation-name:load-5-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(6){animation-name:load-6-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(7){animation-name:load-7-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(8){animation-name:load-8-0a5d9b21}@media (prefers-reduced-motion:reduce){rect[data-v-0a5d9b21]{animation:none}svg[data-v-0a5d9b21]{animation:2.5s ease-in-out infinite pulse-0a5d9b21}}@keyframes pulse-0a5d9b21{0%,to{opacity:1}50%{opacity:.65}}@keyframes load-1-0a5d9b21{0%,80%{transform:translate(0)}5%,20%,60%,75%{transform:translate(-25px,25px)}30%,50%{transform:translate(-75px,75px)}}@keyframes load-2-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(50px)}30%,50%{transform:translateY(50px)}}@keyframes load-3-0a5d9b21{10%,70%{transform:translate(0%)}15%,65%{transform:translateY(-50px)}20%,60%{fill:var(--color-sapphire-500);transform:translateY(-50px)}30%,50%{fill:var(--color-purple-500);transform:translate(-50px)}}@keyframes load-4-0a5d9b21{20%,60%{transform:translate(0%)}30%,50%{transform:translate(50px,-50px)}}@keyframes load-5-0a5d9b21{0%,80%{transform:translate(0%)}5%,20%,60%,75%{transform:translateY(50px)}30%,50%{transform:translate(50px)}}@keyframes load-6-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(-50px)}30%,50%{transform:translateY(-50px)}}@keyframes load-7-0a5d9b21{10%,70%{transform:translate(0%)}15%,20%,60%,65%{transform:translate(50px)}30%,50%{transform:translate(100px,-50px)}}@keyframes load-8-0a5d9b21{15%,65%{transform:translate(0%)}20%,60%{transform:translateY(-50px)}30%,50%{transform:translate(50px,-100px)}}.timer-circle[data-v-7e3c5bda]{stroke-dashoffset:0;animation:linear forwards toast-timer-7e3c5bda}@keyframes toast-timer-7e3c5bda{to{stroke-dashoffset:var(--fa0d20de)}}
|
|
1
|
+
input[data-snap][data-v-5d3904ac]{animation:snap-in-5d3904ac .18s var(--ease-out-strong)}input[data-snap=up][data-v-5d3904ac]{--snap-from:2px}input[data-snap=down][data-v-5d3904ac]{--snap-from:-2px}@keyframes snap-in-5d3904ac{0%{opacity:0;transform:translateY(var(--snap-from))}to{opacity:1;transform:translateY(0)}}@media (prefers-reduced-motion:reduce){input[data-snap][data-v-5d3904ac]{animation:1ms snap-hold-5d3904ac}@keyframes snap-hold-5d3904ac{0%,to{opacity:1}}}rect[data-v-0a5d9b21]{animation-duration:4s;animation-timing-function:cubic-bezier(.77,0,.175,1);animation-iteration-count:infinite}rect[data-v-0a5d9b21]:first-child{animation-name:load-1-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(2){animation-name:load-2-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(3){animation-name:load-3-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(4){animation-name:load-4-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(5){animation-name:load-5-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(6){animation-name:load-6-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(7){animation-name:load-7-0a5d9b21}rect[data-v-0a5d9b21]:nth-child(8){animation-name:load-8-0a5d9b21}@media (prefers-reduced-motion:reduce){rect[data-v-0a5d9b21]{animation:none}svg[data-v-0a5d9b21]{animation:2.5s ease-in-out infinite pulse-0a5d9b21}}@keyframes pulse-0a5d9b21{0%,to{opacity:1}50%{opacity:.65}}@keyframes load-1-0a5d9b21{0%,80%{transform:translate(0)}5%,20%,60%,75%{transform:translate(-25px,25px)}30%,50%{transform:translate(-75px,75px)}}@keyframes load-2-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(50px)}30%,50%{transform:translateY(50px)}}@keyframes load-3-0a5d9b21{10%,70%{transform:translate(0%)}15%,65%{transform:translateY(-50px)}20%,60%{fill:var(--color-sapphire-500);transform:translateY(-50px)}30%,50%{fill:var(--color-purple-500);transform:translate(-50px)}}@keyframes load-4-0a5d9b21{20%,60%{transform:translate(0%)}30%,50%{transform:translate(50px,-50px)}}@keyframes load-5-0a5d9b21{0%,80%{transform:translate(0%)}5%,20%,60%,75%{transform:translateY(50px)}30%,50%{transform:translate(50px)}}@keyframes load-6-0a5d9b21{5%,75%{transform:translate(0%)}10%,20%,60%,70%{transform:translate(-50px)}30%,50%{transform:translateY(-50px)}}@keyframes load-7-0a5d9b21{10%,70%{transform:translate(0%)}15%,20%,60%,65%{transform:translate(50px)}30%,50%{transform:translate(100px,-50px)}}@keyframes load-8-0a5d9b21{15%,65%{transform:translate(0%)}20%,60%{transform:translateY(-50px)}30%,50%{transform:translate(50px,-100px)}}.timer-circle[data-v-7e3c5bda]{stroke-dashoffset:0;animation:linear forwards toast-timer-7e3c5bda}@keyframes toast-timer-7e3c5bda{to{stroke-dashoffset:var(--fa0d20de)}}
|
|
2
2
|
/*$vite$:1*/
|
package/package.json
CHANGED
|
@@ -66,7 +66,12 @@ const iconSizeClasses: Record<SwitchSize, string> = {
|
|
|
66
66
|
squashes the toggle the moment a sibling label wraps onto a second line.
|
|
67
67
|
-->
|
|
68
68
|
<span :class="['relative inline-block shrink-0', trackClasses[size]]">
|
|
69
|
-
|
|
69
|
+
<!--
|
|
70
|
+
`role="switch"` on a native checkbox: it is on/off rather than
|
|
71
|
+
selected/unselected, and the checked state is still the input's own, so
|
|
72
|
+
no `aria-checked` is set -- the browser derives it from the checkbox.
|
|
73
|
+
-->
|
|
74
|
+
<input v-bind="$attrs" type="checkbox" role="switch" class="peer sr-only" v-model="model" />
|
|
70
75
|
<span
|
|
71
76
|
:class="[
|
|
72
77
|
'absolute inset-0 overflow-hidden rounded-[30px] border transition-colors duration-200',
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The steps the slider can land on: the `max-w-*` sizes, with the px numbers
|
|
4
|
+
* mirroring their values (rem × 16). These are Tailwind's own container sizes,
|
|
5
|
+
* not Figma tokens -- `tailwind/` defines no `--container-*`, so the token sync
|
|
6
|
+
* cannot move them. The desync to watch for is a Tailwind major that renames or
|
|
7
|
+
* renumbers the scale: nothing here fails when that happens, the numbers just
|
|
8
|
+
* stop matching the CSS, so this list has to be checked by hand on an upgrade.
|
|
9
|
+
*/
|
|
10
|
+
export const EDITOR_MAX_WIDTH_STEPS = [
|
|
11
|
+
{ token: 'xs', px: 320 },
|
|
12
|
+
{ token: 'sm', px: 384 },
|
|
13
|
+
{ token: 'md', px: 448 },
|
|
14
|
+
{ token: 'lg', px: 512 },
|
|
15
|
+
{ token: 'xl', px: 576 },
|
|
16
|
+
{ token: '2xl', px: 672 },
|
|
17
|
+
{ token: '3xl', px: 768 },
|
|
18
|
+
{ token: '4xl', px: 896 },
|
|
19
|
+
{ token: '5xl', px: 1024 },
|
|
20
|
+
{ token: '6xl', px: 1152 },
|
|
21
|
+
{ token: '7xl', px: 1280 },
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The maximum width as the editor needs it: the Tailwind class itself, so the
|
|
26
|
+
* consumer can put the model value straight on the element it styles.
|
|
27
|
+
*/
|
|
28
|
+
export type EditorMaxWidthValue = `max-w-${(typeof EDITOR_MAX_WIDTH_STEPS)[number]['token']}`
|
|
29
|
+
|
|
30
|
+
/** Overridable copy of the value readout. Every key is optional. */
|
|
31
|
+
export interface EditorMaxWidthMessages {
|
|
32
|
+
/**
|
|
33
|
+
* Unit shown behind the value. Defaults to `'px'`. Keep it to a short symbol
|
|
34
|
+
* such as `'px'` or `'pt'`: the readout is only as wide as `1280 px` needs,
|
|
35
|
+
* and a spelled-out unit grows it and pushes the slider in.
|
|
36
|
+
*/
|
|
37
|
+
unit?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface NexxtEditorMaxWidthProps {
|
|
41
|
+
/**
|
|
42
|
+
* Header text of the settings block. Defaults to the Dutch
|
|
43
|
+
* `'Vaste breedte'` -- switched off the page runs full width, so the setting
|
|
44
|
+
* is the fixed width, not a maximum. The library has no i18n layer, so a
|
|
45
|
+
* consumer in another language passes its own copy in.
|
|
46
|
+
*/
|
|
47
|
+
label?: string
|
|
48
|
+
/**
|
|
49
|
+
* Copy of the value readout. Merged per key, so overriding one key keeps the
|
|
50
|
+
* defaults of the others. Copy is never translated inside the component.
|
|
51
|
+
*/
|
|
52
|
+
messages?: EditorMaxWidthMessages
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_MESSAGES = {
|
|
56
|
+
unit: 'px',
|
|
57
|
+
} satisfies Required<EditorMaxWidthMessages>
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<script lang="ts" setup>
|
|
61
|
+
import { computed } from 'vue'
|
|
62
|
+
import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
|
|
63
|
+
import NexxtSlider from '../../../atoms/Slider/Slider.vue'
|
|
64
|
+
|
|
65
|
+
defineOptions({ name: 'NexxtEditorMaxWidth' })
|
|
66
|
+
|
|
67
|
+
const { label = 'Vaste breedte', messages } = defineProps<NexxtEditorMaxWidthProps>()
|
|
68
|
+
|
|
69
|
+
/** The selected maximum width, as the Tailwind class to apply. */
|
|
70
|
+
const model = defineModel<EditorMaxWidthValue>({ required: true })
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Open state of the block. Switched off means the page runs full width, which
|
|
74
|
+
* is the absence of a class rather than a value of its own: the consumer simply
|
|
75
|
+
* writes no `max-w-*` then. The chosen width is kept while the block is
|
|
76
|
+
* collapsed, so switching back on restores it.
|
|
77
|
+
*/
|
|
78
|
+
const enabled = defineModel<boolean>('enabled', { default: true })
|
|
79
|
+
|
|
80
|
+
const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
|
|
81
|
+
|
|
82
|
+
const maxIndex = EDITOR_MAX_WIDTH_STEPS.length - 1
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Position of the current value on the step list. A value that is not on the
|
|
86
|
+
* list -- a stored `max-w-prose` from before this component, say -- falls back
|
|
87
|
+
* to the first step instead of leaving the thumb off the track; the first
|
|
88
|
+
* change writes a valid class back.
|
|
89
|
+
*/
|
|
90
|
+
const stepIndex = computed(() =>
|
|
91
|
+
Math.max(
|
|
92
|
+
0,
|
|
93
|
+
EDITOR_MAX_WIDTH_STEPS.findIndex(({ token }) => `max-w-${token}` === model.value),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
// The slider moves over the index, not over the px value, so its model is
|
|
98
|
+
// translated both ways here.
|
|
99
|
+
const sliderValue = computed({
|
|
100
|
+
get: () => [stepIndex.value],
|
|
101
|
+
set: ([index]) => {
|
|
102
|
+
model.value = `max-w-${EDITOR_MAX_WIDTH_STEPS[index].token}`
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const displayValue = computed(
|
|
107
|
+
() => `${EDITOR_MAX_WIDTH_STEPS[stepIndex.value].px} ${copy.value.unit}`,
|
|
108
|
+
)
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<template>
|
|
112
|
+
<NexxtEditorSettingsBlock v-model="enabled" :label="label" switchable>
|
|
113
|
+
<div class="flex items-center gap-4">
|
|
114
|
+
<!--
|
|
115
|
+
Slider's own `showValue` prints its model, which here is the step index
|
|
116
|
+
rather than the width, so the readout is rendered next to it instead.
|
|
117
|
+
The thumb has the same problem in `aria-valuenow`, which
|
|
118
|
+
`aria-valuetext` overrides with the string the readout shows. The block
|
|
119
|
+
label is the switch's own label, not a <label> for the slider, so the
|
|
120
|
+
name goes on the thumb explicitly.
|
|
121
|
+
-->
|
|
122
|
+
<NexxtSlider
|
|
123
|
+
v-model="sliderValue"
|
|
124
|
+
:min="0"
|
|
125
|
+
:max="maxIndex"
|
|
126
|
+
:step="1"
|
|
127
|
+
:aria-label="label"
|
|
128
|
+
:aria-valuetext="displayValue"
|
|
129
|
+
/>
|
|
130
|
+
|
|
131
|
+
<!--
|
|
132
|
+
`min-w-16` rather than the `min-w-12` of the narrower editor sliders:
|
|
133
|
+
`1280 px` does not fit in 12. That plus `shrink-0` is what fixes the
|
|
134
|
+
width of the box, so the slider keeps its size while dragging;
|
|
135
|
+
`tabular-nums` only stops the digits from jittering inside it.
|
|
136
|
+
gray-700, not the gray-500 Slider uses for its own readout: axe puts
|
|
137
|
+
gray-500 on white at 1.84:1, well under the 4.5:1 it needs at this size.
|
|
138
|
+
-->
|
|
139
|
+
<span
|
|
140
|
+
class="min-w-16 shrink-0 text-right small-normal whitespace-nowrap text-gray-700 tabular-nums"
|
|
141
|
+
>
|
|
142
|
+
{{ displayValue }}
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
</NexxtEditorSettingsBlock>
|
|
146
|
+
</template>
|
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
import type { PaddingIconSide } from './_PaddingIcon.vue'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The steps a field can land on, as the numeric part of the Tailwind class
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* The steps a field can land on, as the numeric part of the Tailwind class:
|
|
6
|
+
* every integer `--spacing-*` token the theme defines. The list thins out as it
|
|
7
|
+
* climbs -- 13 and 15 are absent, and past 16 it moves in fours and then
|
|
8
|
+
* eights -- so snapping over this list rather than over the px value keeps the
|
|
9
|
+
* field off classes that have no token behind them.
|
|
9
10
|
*/
|
|
10
|
-
const PADDING_STEPS = [
|
|
11
|
+
const PADDING_STEPS = [
|
|
12
|
+
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64,
|
|
13
|
+
72, 80, 96,
|
|
14
|
+
] as const
|
|
11
15
|
|
|
12
|
-
/**
|
|
13
|
-
const
|
|
16
|
+
/** The spacing scale is built on 0.25rem, so `p-4` is 4 × 4 = 16 px. */
|
|
17
|
+
const PX_PER_STEP = 4
|
|
18
|
+
|
|
19
|
+
/** Highest value a field takes, in px: the top step is `p-96`, which is 384 px. */
|
|
20
|
+
const MAX_PX = PADDING_STEPS[PADDING_STEPS.length - 1] * PX_PER_STEP
|
|
14
21
|
|
|
15
22
|
const ALL_SIDES: PaddingIconSide[] = ['top', 'right', 'bottom', 'left']
|
|
16
23
|
|
|
@@ -36,6 +43,12 @@ export interface EditorPaddingMessages {
|
|
|
36
43
|
* fields. Defaults to `'Padding per zijde'`.
|
|
37
44
|
*/
|
|
38
45
|
toggle?: string
|
|
46
|
+
/**
|
|
47
|
+
* Unit the fields show and read, appended to every accessible name. The
|
|
48
|
+
* values themselves are px whatever this says, so it is copy, not a
|
|
49
|
+
* conversion. Defaults to `'px'`.
|
|
50
|
+
*/
|
|
51
|
+
unit?: string
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
export interface NexxtEditorPaddingProps {
|
|
@@ -61,6 +74,7 @@ const DEFAULT_MESSAGES = {
|
|
|
61
74
|
bottom: 'Onder',
|
|
62
75
|
left: 'Links',
|
|
63
76
|
toggle: 'Padding per zijde',
|
|
77
|
+
unit: 'px',
|
|
64
78
|
} satisfies Required<EditorPaddingMessages>
|
|
65
79
|
|
|
66
80
|
/** Which sides a `p*-` prefix writes to. */
|
|
@@ -105,15 +119,15 @@ const serialize = ({ top, right, bottom, left }: PaddingSides): string => {
|
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* value further than the
|
|
122
|
+
* Turn a px value into a step: clamp to 0-384 and take the nearest one. A tie
|
|
123
|
+
* goes to the lower step, so 14 px becomes 12 and 60 px becomes 56; that way a
|
|
124
|
+
* value in between never lands further away than the step above it.
|
|
111
125
|
*/
|
|
112
|
-
const snap = (
|
|
113
|
-
const clamped = Math.min(Math.max(
|
|
126
|
+
const snap = (px: number): number => {
|
|
127
|
+
const clamped = Math.min(Math.max(px, 0), MAX_PX)
|
|
114
128
|
|
|
115
129
|
return PADDING_STEPS.reduce((best, step) =>
|
|
116
|
-
Math.abs(step - clamped) < Math.abs(best - clamped) ? step : best,
|
|
130
|
+
Math.abs(step * PX_PER_STEP - clamped) < Math.abs(best * PX_PER_STEP - clamped) ? step : best,
|
|
117
131
|
)
|
|
118
132
|
}
|
|
119
133
|
</script>
|
|
@@ -198,25 +212,53 @@ const secondaryFields = computed<PaddingField[]>(() => [
|
|
|
198
212
|
// from the tab order. Same trick as EditorSettingsBlock.
|
|
199
213
|
const secondaryInert = computed(() => (expanded.value ? undefined : true))
|
|
200
214
|
|
|
201
|
-
//
|
|
202
|
-
// carries it
|
|
203
|
-
// what keeps Vue from wiping the character out from under the caret.
|
|
215
|
+
// The fields speak px, not steps: `p-6` reads as 24. A 0 is shown as an empty
|
|
216
|
+
// field so the placeholder carries it, and an empty field commits back as a 0.
|
|
204
217
|
const display = (side: PaddingIconSide) =>
|
|
205
|
-
sides.value[side] === 0 ? '' : String(sides.value[side])
|
|
218
|
+
sides.value[side] === 0 ? '' : String(sides.value[side] * PX_PER_STEP)
|
|
206
219
|
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
|
|
220
|
+
const apply = (next: PaddingSides) => {
|
|
221
|
+
// A change that changes nothing -- committing 30 where the value is already
|
|
222
|
+
// 28 px, arrowing up at the ceiling, closing a symmetrical block -- writes
|
|
223
|
+
// nothing either, so the editor does not see an edit that was not one.
|
|
224
|
+
if (ALL_SIDES.every((side) => next[side] === sides.value[side])) return
|
|
210
225
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
226
|
+
sides.value = next
|
|
227
|
+
model.value = serialize(next)
|
|
228
|
+
}
|
|
214
229
|
|
|
230
|
+
const write = (field: PaddingField, step: number) => {
|
|
215
231
|
const next = { ...sides.value }
|
|
216
232
|
for (const side of field.targets) next[side] = step
|
|
217
|
-
sides.value = next
|
|
218
233
|
|
|
219
|
-
|
|
234
|
+
apply(next)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A confirmed field, in px: blur or Enter, not a keystroke. */
|
|
238
|
+
const onCommit = (field: PaddingField, px: number) => write(field, snap(px))
|
|
239
|
+
|
|
240
|
+
/** One step along the list, so the arrow keys never stop on a missing token. */
|
|
241
|
+
const onStep = (field: PaddingField, direction: 1 | -1) => {
|
|
242
|
+
const index = PADDING_STEPS.indexOf(sides.value[field.source] as (typeof PADDING_STEPS)[number])
|
|
243
|
+
// No wrapping: the ends of the list are the ends of the range.
|
|
244
|
+
const next = Math.min(Math.max(index + direction, 0), PADDING_STEPS.length - 1)
|
|
245
|
+
|
|
246
|
+
write(field, PADDING_STEPS[next])
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Closing the block makes the two fields that stay visible the truth: whatever
|
|
251
|
+
* the hidden bottom and left were, they follow top and right. That is written
|
|
252
|
+
* straight away rather than on the next edit, so what is on screen and what is
|
|
253
|
+
* on the element never disagree -- `pt-4 pr-2 pb-6 pl-2` closes as `px-2 py-4`,
|
|
254
|
+
* and the horizontal field then reads what Rechts said. Opening changes nothing:
|
|
255
|
+
* it only shows the two values that were already there.
|
|
256
|
+
*/
|
|
257
|
+
const onToggle = () => {
|
|
258
|
+
expanded.value = !expanded.value
|
|
259
|
+
if (expanded.value) return
|
|
260
|
+
|
|
261
|
+
apply({ ...sides.value, bottom: sides.value.top, left: sides.value.right })
|
|
220
262
|
}
|
|
221
263
|
</script>
|
|
222
264
|
|
|
@@ -235,8 +277,9 @@ const onInput = (field: PaddingField, typed: string) => {
|
|
|
235
277
|
:sides="field.targets"
|
|
236
278
|
:label="field.label"
|
|
237
279
|
:value="display(field.source)"
|
|
238
|
-
:
|
|
239
|
-
@
|
|
280
|
+
:unit="copy.unit"
|
|
281
|
+
@commit="onCommit(field, $event)"
|
|
282
|
+
@step="onStep(field, $event)"
|
|
240
283
|
/>
|
|
241
284
|
|
|
242
285
|
<!--
|
|
@@ -256,7 +299,7 @@ const onInput = (field: PaddingField, typed: string) => {
|
|
|
256
299
|
? 'bg-cornflower-blue-50 text-cornflower-blue-600 ring-cornflower-blue-500'
|
|
257
300
|
: 'text-gray-700 ring-gray-200 hover:text-gray-900',
|
|
258
301
|
]"
|
|
259
|
-
@click="
|
|
302
|
+
@click="onToggle"
|
|
260
303
|
>
|
|
261
304
|
<PaddingIcon :sides="ALL_SIDES" />
|
|
262
305
|
</button>
|
|
@@ -300,8 +343,9 @@ const onInput = (field: PaddingField, typed: string) => {
|
|
|
300
343
|
:sides="field.targets"
|
|
301
344
|
:label="field.label"
|
|
302
345
|
:value="display(field.source)"
|
|
303
|
-
:
|
|
304
|
-
@
|
|
346
|
+
:unit="copy.unit"
|
|
347
|
+
@commit="onCommit(field, $event)"
|
|
348
|
+
@step="onStep(field, $event)"
|
|
305
349
|
/>
|
|
306
350
|
</div>
|
|
307
351
|
</div>
|
|
@@ -1,56 +1,243 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Only plain digits count. `Number.parseInt` would read "1e2" as 1 and "12abc"
|
|
3
|
+
// as 12, which silently turns a typo into a value the user did not ask for.
|
|
4
|
+
const DIGITS = /^\d+$/
|
|
5
|
+
</script>
|
|
6
|
+
|
|
1
7
|
<script lang="ts" setup>
|
|
8
|
+
import { nextTick, ref, useTemplateRef, watch } from 'vue'
|
|
2
9
|
import PaddingIcon from './_PaddingIcon.vue'
|
|
3
10
|
import type { PaddingIconSide } from './_PaddingIcon.vue'
|
|
4
11
|
|
|
5
12
|
export interface NexxtEditorPaddingFieldProps {
|
|
6
13
|
/** Sides the icon marks, which is also what this field edits. */
|
|
7
14
|
sides: PaddingIconSide[]
|
|
8
|
-
/**
|
|
15
|
+
/** Name of the field; the unit is appended to it for the accessible name. */
|
|
9
16
|
label: string
|
|
10
|
-
/** Value as shown,
|
|
17
|
+
/** Value as shown, in the unit below. An empty string falls back to the `0` placeholder. */
|
|
11
18
|
value: string
|
|
12
|
-
/**
|
|
13
|
-
|
|
19
|
+
/** Unit of the value, shown behind the input and part of the accessible name. */
|
|
20
|
+
unit: string
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
defineOptions({ name: 'NexxtEditorPaddingField' })
|
|
17
24
|
|
|
18
|
-
defineProps<NexxtEditorPaddingFieldProps>()
|
|
25
|
+
const { value } = defineProps<NexxtEditorPaddingFieldProps>()
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits<{
|
|
28
|
+
/**
|
|
29
|
+
* The value the user confirmed, on blur or Enter rather than on every
|
|
30
|
+
* keystroke. Reading the text is this component's job because it owns the
|
|
31
|
+
* input; text that is not a number never gets here. Snapping it onto the
|
|
32
|
+
* scale is the parent's, because it owns the class string.
|
|
33
|
+
*/
|
|
34
|
+
commit: [value: number]
|
|
35
|
+
/** One step up or down the parent's list, straight away. */
|
|
36
|
+
step: [direction: 1 | -1]
|
|
37
|
+
}>()
|
|
38
|
+
|
|
39
|
+
const inputEl = useTemplateRef<HTMLInputElement>('input')
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the input shows while it is being edited. It is deliberately not the
|
|
43
|
+
* model: a half-typed "3" on the way to "30" would snap to 4 and fight the
|
|
44
|
+
* caret, so nothing leaves this component until the value is confirmed.
|
|
45
|
+
*/
|
|
46
|
+
const draft = ref(value)
|
|
47
|
+
|
|
48
|
+
/** Direction of the one-shot snap animation, cleared once it has played. */
|
|
49
|
+
const snapped = ref<'up' | 'down'>()
|
|
50
|
+
|
|
51
|
+
// The model stays the source of truth: a change from outside -- the parent
|
|
52
|
+
// snapping a commit included -- replaces the draft.
|
|
53
|
+
watch(
|
|
54
|
+
() => value,
|
|
55
|
+
(next) => {
|
|
56
|
+
draft.value = next
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
/** Focus with the whole value selected, so the next digit replaces it. */
|
|
61
|
+
const selectAll = () => {
|
|
62
|
+
inputEl.value?.focus()
|
|
63
|
+
inputEl.value?.select()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Pointer-down anywhere in the field -- on the icon or the unit as much as on
|
|
68
|
+
* the input -- lands in the input with its value selected. The default is
|
|
69
|
+
* prevented so the browser does not drop a caret inside that selection again on
|
|
70
|
+
* mouse-up.
|
|
71
|
+
*/
|
|
72
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
73
|
+
// Mouse only. Touch and pen keep their native behaviour, because preventing
|
|
74
|
+
// the default there can keep iOS from opening the keyboard -- and `@focus`
|
|
75
|
+
// selects the value on those just the same.
|
|
76
|
+
if (event.pointerType !== 'mouse') return
|
|
77
|
+
if (document.activeElement === inputEl.value) return
|
|
78
|
+
|
|
79
|
+
event.preventDefault()
|
|
80
|
+
selectAll()
|
|
81
|
+
}
|
|
19
82
|
|
|
20
83
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
84
|
+
* Hand the value to the parent and show back whatever it made of it, which is
|
|
85
|
+
* the snapped value, or the value from before for text that was not a number at
|
|
86
|
+
* all. The draft is re-read rather than assumed: a commit that snaps onto the
|
|
87
|
+
* value the field already had leaves the model untouched, so waiting on the prop
|
|
88
|
+
* to change would strand the typed text.
|
|
23
89
|
*/
|
|
24
|
-
const
|
|
90
|
+
const commit = async () => {
|
|
91
|
+
const typed = draft.value.trim()
|
|
92
|
+
|
|
93
|
+
// Clearing the field is how a zero is entered; the placeholder already shows
|
|
94
|
+
// it. Anything that is not a number leaves the field without a commit and is
|
|
95
|
+
// replaced by the old value below, the same as Escape -- guessing at "12a"
|
|
96
|
+
// would put a value on the element that nobody asked for.
|
|
97
|
+
if (typed === '') emit('commit', 0)
|
|
98
|
+
else if (DIGITS.test(typed)) emit('commit', Number(typed))
|
|
99
|
+
|
|
100
|
+
await nextTick()
|
|
101
|
+
draft.value = value
|
|
102
|
+
|
|
103
|
+
// Compared as numbers, so "016" against a field already showing 16 is not a
|
|
104
|
+
// change and does not animate.
|
|
105
|
+
if (!DIGITS.test(typed) || Number(typed) === Number(value || '0')) return
|
|
106
|
+
|
|
107
|
+
// Restart the one-shot: re-setting the direction it already has would not
|
|
108
|
+
// retrigger the animation, so two snaps the same way within 180ms would show
|
|
109
|
+
// only the first.
|
|
110
|
+
snapped.value = undefined
|
|
111
|
+
await nextTick()
|
|
112
|
+
|
|
113
|
+
snapped.value = Number(value || '0') > Number(typed) ? 'up' : 'down'
|
|
114
|
+
}
|
|
25
115
|
|
|
26
|
-
|
|
116
|
+
/** Arrows write immediately, so the draft only has to catch up. */
|
|
117
|
+
const onStep = async (direction: 1 | -1) => {
|
|
118
|
+
emit('step', direction)
|
|
119
|
+
|
|
120
|
+
await nextTick()
|
|
121
|
+
draft.value = value
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const onKeydown = async (event: KeyboardEvent) => {
|
|
125
|
+
if (event.key === 'Enter') {
|
|
126
|
+
event.preventDefault()
|
|
127
|
+
await commit()
|
|
128
|
+
|
|
129
|
+
// Select what it landed on, so the field is ready for the next value the
|
|
130
|
+
// same way focus and Escape leave it.
|
|
131
|
+
return selectAll()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (event.key === 'Escape') {
|
|
135
|
+
// Only swallowed when there is typing to undo. With nothing to revert,
|
|
136
|
+
// Escape belongs to whatever popover or panel the field sits in.
|
|
137
|
+
if (draft.value === value) return
|
|
138
|
+
|
|
139
|
+
event.stopPropagation()
|
|
140
|
+
draft.value = value
|
|
141
|
+
return selectAll()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
145
|
+
// Or the caret would jump to either end of the value as well.
|
|
146
|
+
event.preventDefault()
|
|
147
|
+
return onStep(event.key === 'ArrowUp' ? 1 : -1)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
27
150
|
</script>
|
|
28
151
|
|
|
29
152
|
<template>
|
|
30
153
|
<!--
|
|
31
154
|
Visually a NexxtInputField, rebuilt here: that component forces `min-w-60`
|
|
32
155
|
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.
|
|
156
|
+
for `type="number"` that make no sense against a step list. `cursor-text`
|
|
157
|
+
and the pointer handler make the whole row behave as the input it wraps.
|
|
158
|
+
|
|
159
|
+
The inner spacing is asymmetric, the padding is not: `gap-1` with an extra
|
|
160
|
+
`mr-1` on the icon leaves 8px between the icon and the number but only 4px
|
|
161
|
+
between the number and its unit, which reads as one value and buys the number
|
|
162
|
+
the width it needs. At three digits plus a unit the field has little to spare.
|
|
34
163
|
-->
|
|
35
164
|
<div
|
|
36
|
-
class="flex items-center gap-
|
|
165
|
+
class="flex cursor-text items-center gap-1 overflow-hidden rounded-lg px-3 whitespace-nowrap ring ring-gray-200 transition-colors duration-150 ease-out focus-within:ring-cornflower-blue-500 hover:ring-gray-300 motion-reduce:transition-none"
|
|
166
|
+
@pointerdown="onPointerDown"
|
|
37
167
|
>
|
|
38
|
-
<PaddingIcon :sides="sides" class="text-gray-900" />
|
|
168
|
+
<PaddingIcon :sides="sides" class="mr-1 text-gray-900" />
|
|
39
169
|
|
|
40
170
|
<!--
|
|
41
|
-
`type="number"
|
|
42
|
-
|
|
171
|
+
Text, not `type="number"`: a number input reports an empty `value` for
|
|
172
|
+
anything it cannot parse, so "12a" would arrive as a cleared field and
|
|
173
|
+
snap to 0 mid-word. `inputmode` still asks for the numeric keypad. No
|
|
174
|
+
`maxlength`: it would silently turn a pasted 1000 into 100, where the
|
|
175
|
+
clamp turns it into the 384 the user was reaching for.
|
|
43
176
|
-->
|
|
44
177
|
<input
|
|
45
|
-
|
|
178
|
+
ref="input"
|
|
179
|
+
type="text"
|
|
46
180
|
inputmode="numeric"
|
|
47
|
-
|
|
48
|
-
:max="max"
|
|
181
|
+
autocomplete="off"
|
|
49
182
|
placeholder="0"
|
|
50
|
-
:aria-label="label"
|
|
51
|
-
:value="
|
|
52
|
-
|
|
53
|
-
|
|
183
|
+
:aria-label="`${label} (${unit})`"
|
|
184
|
+
:value="draft"
|
|
185
|
+
:data-snap="snapped"
|
|
186
|
+
class="h-10 min-w-0 flex-1 bg-transparent text-right small-normal text-gray-900 tabular-nums placeholder-gray-700 focus:outline-none"
|
|
187
|
+
@input="draft = ($event.target as HTMLInputElement).value"
|
|
188
|
+
@focus="selectAll"
|
|
189
|
+
@blur="commit"
|
|
190
|
+
@keydown="onKeydown"
|
|
191
|
+
@animationend="snapped = undefined"
|
|
54
192
|
/>
|
|
193
|
+
|
|
194
|
+
<span class="shrink-0 small-normal text-gray-700 select-none" aria-hidden="true">
|
|
195
|
+
{{ unit }}
|
|
196
|
+
</span>
|
|
55
197
|
</div>
|
|
56
198
|
</template>
|
|
199
|
+
|
|
200
|
+
<style scoped>
|
|
201
|
+
/* A committed value that landed on a different step than the one typed is
|
|
202
|
+
replaced rather than edited, so it arrives as its own number: it fades in and
|
|
203
|
+
travels the way the value moved -- up for a higher one, down for a lower. */
|
|
204
|
+
input[data-snap] {
|
|
205
|
+
animation: snap-in 180ms var(--ease-out-strong);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
input[data-snap='up'] {
|
|
209
|
+
--snap-from: 2px;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
input[data-snap='down'] {
|
|
213
|
+
--snap-from: -2px;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
@keyframes snap-in {
|
|
217
|
+
from {
|
|
218
|
+
opacity: 0;
|
|
219
|
+
transform: translateY(var(--snap-from));
|
|
220
|
+
}
|
|
221
|
+
to {
|
|
222
|
+
opacity: 1;
|
|
223
|
+
transform: translateY(0);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* The number itself is the feedback; without motion there is nothing left to
|
|
228
|
+
say that the swap needs, so it just appears. Not `animation: none`: that fires
|
|
229
|
+
no `animationend`, so the attribute would stay on and the next snap in the
|
|
230
|
+
same direction would have nothing to retrigger. A 1ms no-op keeps the event. */
|
|
231
|
+
@media (prefers-reduced-motion: reduce) {
|
|
232
|
+
input[data-snap] {
|
|
233
|
+
animation: snap-hold 1ms;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@keyframes snap-hold {
|
|
237
|
+
from,
|
|
238
|
+
to {
|
|
239
|
+
opacity: 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
</style>
|
package/src/components.json
CHANGED
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"NexxtEditorGap": "components/editor/molecules/EditorGap/EditorGap.vue",
|
|
74
74
|
"NexxtEditorHeadingType": "components/editor/molecules/EditorHeadingType/EditorHeadingType.vue",
|
|
75
75
|
"NexxtEditorJustifyContent": "components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue",
|
|
76
|
+
"NexxtEditorMaxWidth": "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
|
|
76
77
|
"NexxtEditorPadding": "components/editor/molecules/EditorPadding/EditorPadding.vue",
|
|
77
78
|
"NexxtEditorRadius": "components/editor/molecules/EditorRadius/EditorRadius.vue",
|
|
78
79
|
"NexxtEditorTextAlign": "components/editor/molecules/EditorTextAlign/EditorTextAlign.vue"
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,11 @@ export type {
|
|
|
56
56
|
EditorJustifyContentMessages,
|
|
57
57
|
EditorJustifyContentValue,
|
|
58
58
|
} from './components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue'
|
|
59
|
+
export { EDITOR_MAX_WIDTH_STEPS } from './components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue'
|
|
60
|
+
export type {
|
|
61
|
+
EditorMaxWidthMessages,
|
|
62
|
+
EditorMaxWidthValue,
|
|
63
|
+
} from './components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue'
|
|
59
64
|
export type { EditorPaddingMessages } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
|
|
60
65
|
export type {
|
|
61
66
|
NexxtTemplateCardContentFeature,
|
|
@@ -87,6 +92,7 @@ export { default as NexxtEditorFlexDirection } from './components/editor/molecul
|
|
|
87
92
|
export { default as NexxtEditorGap } from './components/editor/molecules/EditorGap/EditorGap.vue'
|
|
88
93
|
export { default as NexxtEditorHeadingType } from './components/editor/molecules/EditorHeadingType/EditorHeadingType.vue'
|
|
89
94
|
export { default as NexxtEditorJustifyContent } from './components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue'
|
|
95
|
+
export { default as NexxtEditorMaxWidth } from './components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue'
|
|
90
96
|
export { default as NexxtEditorPadding } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
|
|
91
97
|
export { default as NexxtEditorRadius } from './components/editor/molecules/EditorRadius/EditorRadius.vue'
|
|
92
98
|
export { default as NexxtEditorSettingsBlock } from './components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
|