@nexxtmove/ui 1.45.0 → 1.47.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/atoms/EditorSettingsBlock/EditorSettingsBlock.vue.d.ts +2 -2
- package/dist/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue.d.ts +45 -0
- package/dist/components/editor/atoms/EditorSettingsGroup/editorSettingsGroupContext.d.ts +23 -0
- package/dist/components/editor/molecules/EditorAlignItems/EditorAlignItems.vue.d.ts +6 -0
- package/dist/components/editor/molecules/EditorDashPattern/EditorDashPattern.vue.d.ts +61 -0
- package/dist/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue.d.ts +6 -0
- package/dist/components/editor/molecules/EditorLineStyle/EditorLineStyle.vue.d.ts +78 -0
- package/dist/components/editor/molecules/EditorPadding/EditorPadding.vue.d.ts +11 -0
- package/dist/components/editor/molecules/EditorShadow/EditorShadow.vue.d.ts +72 -0
- package/dist/components/editor/molecules/EditorTextAlign/EditorTextAlign.vue.d.ts +8 -0
- package/dist/components/molecules/ToggleGroup/ToggleGroup.vue.d.ts +22 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.js +2436 -1972
- package/dist/nuxt.js +4 -0
- package/package.json +3 -3
- package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue +36 -5
- package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue +228 -0
- package/src/components/editor/atoms/EditorSettingsGroup/editorSettingsGroupContext.ts +27 -0
- package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.vue +17 -3
- package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue +1 -1
- package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.vue +139 -0
- package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue +1 -1
- package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue +17 -3
- package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.vue +230 -0
- package/src/components/editor/molecules/EditorPadding/EditorPadding.vue +102 -17
- package/src/components/editor/molecules/EditorShadow/EditorShadow.vue +106 -0
- package/src/components/editor/molecules/EditorSize/EditorSize.vue +1 -1
- package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.vue +19 -3
- package/src/components/molecules/ToggleGroup/ToggleGroup.vue +105 -11
- package/src/components.json +4 -0
- package/src/index.ts +29 -0
- package/tailwind/nexxtmove.tailwind.custom.css +9 -4
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The line styles the editor offers, in the order they are shown. The model is
|
|
4
|
+
* the token itself, not a class: what a style puts on the element is more than
|
|
5
|
+
* one class and depends on the thickness and the breakpoint, so that
|
|
6
|
+
* translation belongs to the consumer. The labels arrive through `messages`,
|
|
7
|
+
* keyed by token.
|
|
8
|
+
*/
|
|
9
|
+
export const EDITOR_LINE_STYLE_OPTIONS = [
|
|
10
|
+
{ token: 'none' },
|
|
11
|
+
{ token: 'solid' },
|
|
12
|
+
{ token: 'dashed' },
|
|
13
|
+
{ token: 'dotted' },
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
/** A line style, as one of the tokens of `EDITOR_LINE_STYLE_OPTIONS`. */
|
|
17
|
+
export type EditorLineStyleValue = (typeof EDITOR_LINE_STYLE_OPTIONS)[number]['token']
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The thicknesses the slider can land on, in px. There is no Tailwind token
|
|
21
|
+
* behind them: the consumer writes the value into a CSS variable.
|
|
22
|
+
*/
|
|
23
|
+
export const EDITOR_LINE_THICKNESS_STEPS = [1, 2, 3, 4, 6, 8] as const
|
|
24
|
+
|
|
25
|
+
/** A line thickness in px, as one of `EDITOR_LINE_THICKNESS_STEPS`. */
|
|
26
|
+
export type EditorLineThickness = (typeof EDITOR_LINE_THICKNESS_STEPS)[number]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The smallest thickness per style. A style that is not in here starts at the
|
|
30
|
+
* first step. Dotted starts at 2: at 1 px a round dot is a blurred pixel.
|
|
31
|
+
*/
|
|
32
|
+
export const EDITOR_LINE_MIN_THICKNESS: Partial<Record<EditorLineStyleValue, EditorLineThickness>> =
|
|
33
|
+
{ dotted: 2 }
|
|
34
|
+
|
|
35
|
+
/** Overridable copy of the options and the thickness row. Every key is optional. */
|
|
36
|
+
export interface EditorLineStyleMessages {
|
|
37
|
+
/** Name of the `none` option. Defaults to `'Geen'`. */
|
|
38
|
+
none?: string
|
|
39
|
+
/** Name of the `solid` option. Defaults to `'Doorgetrokken'`. */
|
|
40
|
+
solid?: string
|
|
41
|
+
/** Name of the `dashed` option. Defaults to `'Gestreept'`. */
|
|
42
|
+
dashed?: string
|
|
43
|
+
/** Name of the `dotted` option. Defaults to `'Gestippeld'`. */
|
|
44
|
+
dotted?: string
|
|
45
|
+
/** Heading above the slider, and the accessible name of its thumb. Defaults to `'Dikte'`. */
|
|
46
|
+
thickness?: string
|
|
47
|
+
/** Unit shown behind the thickness. Defaults to `'px'`. */
|
|
48
|
+
unit?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface NexxtEditorLineStyleProps {
|
|
52
|
+
/**
|
|
53
|
+
* Header text of the settings block, and with it the name of the option
|
|
54
|
+
* group. Defaults to the Dutch `'Lijn'`; the library has no i18n layer, so a
|
|
55
|
+
* consumer in another language passes its own copy in.
|
|
56
|
+
*/
|
|
57
|
+
label?: string
|
|
58
|
+
/**
|
|
59
|
+
* Copy of the options and the thickness row. Defaults are Dutch and are
|
|
60
|
+
* merged per key, so overriding one keeps the defaults of the others. Copy is
|
|
61
|
+
* never translated inside the component.
|
|
62
|
+
*/
|
|
63
|
+
messages?: EditorLineStyleMessages
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DEFAULT_MESSAGES = {
|
|
67
|
+
none: 'Geen',
|
|
68
|
+
solid: 'Doorgetrokken',
|
|
69
|
+
dashed: 'Gestreept',
|
|
70
|
+
dotted: 'Gestippeld',
|
|
71
|
+
thickness: 'Dikte',
|
|
72
|
+
unit: 'px',
|
|
73
|
+
} satisfies Required<EditorLineStyleMessages>
|
|
74
|
+
|
|
75
|
+
/** The border style each drawn swatch uses; `none` shows an icon instead. */
|
|
76
|
+
const SWATCH: Record<Exclude<EditorLineStyleValue, 'none'>, string> = {
|
|
77
|
+
solid: 'border-solid',
|
|
78
|
+
dashed: 'border-dashed',
|
|
79
|
+
dotted: 'border-dotted',
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<script lang="ts" setup>
|
|
84
|
+
import { computed } from 'vue'
|
|
85
|
+
import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
|
|
86
|
+
import NexxtIcon from '../../../atoms/Icon/Icon.vue'
|
|
87
|
+
import NexxtSlider from '../../../atoms/Slider/Slider.vue'
|
|
88
|
+
import NexxtToggleGroup from '../../../molecules/ToggleGroup/ToggleGroup.vue'
|
|
89
|
+
import type { NexxtToggleGroupOption } from '../../../molecules/ToggleGroup/ToggleGroup.vue'
|
|
90
|
+
|
|
91
|
+
defineOptions({ name: 'NexxtEditorLineStyle' })
|
|
92
|
+
|
|
93
|
+
const { label = 'Lijn', messages } = defineProps<NexxtEditorLineStyleProps>()
|
|
94
|
+
|
|
95
|
+
/** The selected line style, as a token. */
|
|
96
|
+
const model = defineModel<EditorLineStyleValue>({ required: true })
|
|
97
|
+
|
|
98
|
+
/** The line thickness in px. */
|
|
99
|
+
const thickness = defineModel<EditorLineThickness>('thickness', { required: true })
|
|
100
|
+
|
|
101
|
+
const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
|
|
102
|
+
|
|
103
|
+
// Drawings only and deliberately without tooltips, like the other editor
|
|
104
|
+
// toggle groups (TextAlign, JustifyContent, AlignItems, BackgroundSize). The
|
|
105
|
+
// copy travels as `ariaLabel`, so every option still has an accessible name.
|
|
106
|
+
const options = computed<NexxtToggleGroupOption[]>(() =>
|
|
107
|
+
EDITOR_LINE_STYLE_OPTIONS.map(({ token }) => ({
|
|
108
|
+
value: token,
|
|
109
|
+
ariaLabel: copy.value[token],
|
|
110
|
+
})),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const swatchOf = (value: string) => SWATCH[value as Exclude<EditorLineStyleValue, 'none'>]
|
|
114
|
+
|
|
115
|
+
// ToggleGroup types its model as the open `string` of its options. The only
|
|
116
|
+
// values it can emit are the tokens built above, so the narrowing is safe.
|
|
117
|
+
const selected = computed({
|
|
118
|
+
get: () => model.value,
|
|
119
|
+
set: (value: string) => {
|
|
120
|
+
model.value = value as EditorLineStyleValue
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
/** The steps the current style allows: dotted skips the ones below its minimum. */
|
|
125
|
+
const steps = computed(() => {
|
|
126
|
+
const min = EDITOR_LINE_MIN_THICKNESS[model.value] ?? EDITOR_LINE_THICKNESS_STEPS[0]
|
|
127
|
+
return EDITOR_LINE_THICKNESS_STEPS.filter((step) => step >= min)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Position of the thickness on the allowed steps. A value that is not on them
|
|
132
|
+
* -- a 5 from outside, or a 1 while the style is dotted -- shows as the first
|
|
133
|
+
* allowed step, and is deliberately not written back: the consumer bumps the
|
|
134
|
+
* thickness in the same patch as the style change, so it stays one undo step.
|
|
135
|
+
*/
|
|
136
|
+
const stepIndex = computed(() => Math.max(0, steps.value.indexOf(thickness.value)))
|
|
137
|
+
|
|
138
|
+
// The slider moves over the index, not over the px value.
|
|
139
|
+
const sliderValue = computed({
|
|
140
|
+
get: () => [stepIndex.value],
|
|
141
|
+
set: ([index]) => {
|
|
142
|
+
thickness.value = steps.value[index]
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const displayValue = computed(() => `${steps.value[stepIndex.value]} ${copy.value.unit}`)
|
|
147
|
+
|
|
148
|
+
const open = computed(() => model.value !== 'none')
|
|
149
|
+
|
|
150
|
+
// `:inert="false"` renders `inert="false"`, which still makes the row inert;
|
|
151
|
+
// only `undefined` removes the attribute.
|
|
152
|
+
const inert = computed(() => (open.value ? undefined : true))
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
<template>
|
|
156
|
+
<NexxtEditorSettingsBlock :label="label">
|
|
157
|
+
<!--
|
|
158
|
+
The block label is not a <label> (a <p>, or a heading when promoted), so
|
|
159
|
+
the toolbar is named explicitly; `aria-label` falls through to
|
|
160
|
+
ToggleGroup's root, which is the toolbar.
|
|
161
|
+
-->
|
|
162
|
+
<NexxtToggleGroup v-model="selected" :options="options" :aria-label="label">
|
|
163
|
+
<!--
|
|
164
|
+
Rendered on the button and again in the overlay that shows the active
|
|
165
|
+
colour, so it draws in currentColor and takes ToggleGroup's colours:
|
|
166
|
+
gray-700 at rest, gray-900 on hover, the active colour when selected.
|
|
167
|
+
-->
|
|
168
|
+
<template #option="{ option }">
|
|
169
|
+
<NexxtIcon v-if="option.value === 'none'" name="ban" />
|
|
170
|
+
<span
|
|
171
|
+
v-else
|
|
172
|
+
aria-hidden="true"
|
|
173
|
+
class="block h-0 w-6 border-t-2 border-current"
|
|
174
|
+
:class="swatchOf(option.value)"
|
|
175
|
+
/>
|
|
176
|
+
</template>
|
|
177
|
+
</NexxtToggleGroup>
|
|
178
|
+
|
|
179
|
+
<!--
|
|
180
|
+
Collapses when there is no line: there is no thickness to set. The row
|
|
181
|
+
stays mounted so its height can animate, and `inert` keeps the slider out
|
|
182
|
+
of the tab order meanwhile.
|
|
183
|
+
-->
|
|
184
|
+
<div
|
|
185
|
+
data-testid="editor-line-style-thickness"
|
|
186
|
+
:inert="inert"
|
|
187
|
+
:class="[
|
|
188
|
+
'grid transition-[grid-template-rows] duration-200 ease-out-strong motion-reduce:transition-none',
|
|
189
|
+
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
|
190
|
+
]"
|
|
191
|
+
>
|
|
192
|
+
<!--
|
|
193
|
+
`-mx-1` / `px-1` give the thumb's focus ring room inside the clipping
|
|
194
|
+
box, the same trick as EditorPadding's second row.
|
|
195
|
+
-->
|
|
196
|
+
<div
|
|
197
|
+
:class="[
|
|
198
|
+
'-mx-1 min-h-0 overflow-hidden transition-opacity duration-200 ease-out-strong motion-reduce:transition-none',
|
|
199
|
+
open ? 'opacity-100' : 'opacity-0',
|
|
200
|
+
]"
|
|
201
|
+
>
|
|
202
|
+
<!--
|
|
203
|
+
Dikte is a setting of the line in its own right, not a field of the
|
|
204
|
+
style: setting typography and spacing (gray-900, a 16px step up), all
|
|
205
|
+
of it inside the clipping box so a collapsed row leaves nothing behind.
|
|
206
|
+
-->
|
|
207
|
+
<div class="flex flex-col gap-1 px-1 pt-4 pb-1">
|
|
208
|
+
<p class="small-normal text-gray-900">{{ copy.thickness }}</p>
|
|
209
|
+
<div class="flex items-center gap-4">
|
|
210
|
+
<!--
|
|
211
|
+
The slider's model is the step index, so the readout is rendered
|
|
212
|
+
here and `aria-valuetext` says the px instead of the index.
|
|
213
|
+
-->
|
|
214
|
+
<NexxtSlider
|
|
215
|
+
v-model="sliderValue"
|
|
216
|
+
:min="0"
|
|
217
|
+
:max="steps.length - 1"
|
|
218
|
+
:step="1"
|
|
219
|
+
:aria-label="copy.thickness"
|
|
220
|
+
:aria-valuetext="displayValue"
|
|
221
|
+
/>
|
|
222
|
+
<span class="min-w-12 shrink-0 text-right small-normal whitespace-nowrap text-gray-700">
|
|
223
|
+
{{ displayValue }}
|
|
224
|
+
</span>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</NexxtEditorSettingsBlock>
|
|
230
|
+
</template>
|
|
@@ -98,6 +98,40 @@ export interface NexxtEditorPaddingProps {
|
|
|
98
98
|
* is never translated inside the component.
|
|
99
99
|
*/
|
|
100
100
|
messages?: EditorPaddingMessages
|
|
101
|
+
/**
|
|
102
|
+
* Which sides the block edits. `'all'` offers all four. `'vertical'` offers
|
|
103
|
+
* top and bottom only, for an element where horizontal padding has no
|
|
104
|
+
* sensible effect: collapsed it is one field that writes `py-N`, expanded it
|
|
105
|
+
* is Boven and Onder in the same row, written as `pt-N pb-N`. Horizontal
|
|
106
|
+
* padding in the incoming value is ignored there and drops out on the first
|
|
107
|
+
* write. `'horizontal'` is the mirror image: one field writing `px-N`, or
|
|
108
|
+
* Links and Rechts in one row written as `pl-N pr-N`, with incoming vertical
|
|
109
|
+
* padding ignored. Defaults to `'all'`.
|
|
110
|
+
*/
|
|
111
|
+
sides?: 'all' | 'vertical' | 'horizontal'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The one axis a two-sided block edits. */
|
|
115
|
+
type PaddingAxis = 'vertical' | 'horizontal'
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Per axis: the two sides in reading order (the first is the one the collapsed
|
|
119
|
+
* field reads, and the one the other follows on closing), the collapsed
|
|
120
|
+
* field's message key, and the utility prefixes it writes.
|
|
121
|
+
*/
|
|
122
|
+
const AXES: Record<
|
|
123
|
+
PaddingAxis,
|
|
124
|
+
{ sides: [PaddingIconSide, PaddingIconSide]; ignored: PaddingIconSide[]; both: string }
|
|
125
|
+
> = {
|
|
126
|
+
vertical: { sides: ['top', 'bottom'], ignored: ['left', 'right'], both: 'py' },
|
|
127
|
+
horizontal: { sides: ['left', 'right'], ignored: ['top', 'bottom'], both: 'px' },
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SIDE_PREFIX: Record<PaddingIconSide, string> = {
|
|
131
|
+
top: 'pt',
|
|
132
|
+
right: 'pr',
|
|
133
|
+
bottom: 'pb',
|
|
134
|
+
left: 'pl',
|
|
101
135
|
}
|
|
102
136
|
|
|
103
137
|
const DEFAULT_MESSAGES = {
|
|
@@ -129,7 +163,7 @@ const TOKEN = /^p([xytrbl]?)-(\d+)$/
|
|
|
129
163
|
* padding utility on the step list is ignored, and a later token wins over an
|
|
130
164
|
* earlier one -- the same thing Tailwind's own cascade does for `p-4 pt-2`.
|
|
131
165
|
*/
|
|
132
|
-
const parse = (value: string): PaddingSides => {
|
|
166
|
+
const parse = (value: string, axis?: PaddingAxis): PaddingSides => {
|
|
133
167
|
const sides: PaddingSides = { top: 0, right: 0, bottom: 0, left: 0 }
|
|
134
168
|
|
|
135
169
|
for (const token of value.split(/\s+/)) {
|
|
@@ -142,6 +176,11 @@ const parse = (value: string): PaddingSides => {
|
|
|
142
176
|
for (const side of PREFIX_SIDES[match[1]]) sides[side] = step
|
|
143
177
|
}
|
|
144
178
|
|
|
179
|
+
// A two-sided block shows no field for the other axis, so whatever the value
|
|
180
|
+
// says there is not part of what it edits. Zeroed rather than kept, so nothing
|
|
181
|
+
// hidden can make an edit look like no edit, and nothing of it is written back.
|
|
182
|
+
if (axis) for (const side of AXES[axis].ignored) sides[side] = 0
|
|
183
|
+
|
|
145
184
|
return sides
|
|
146
185
|
}
|
|
147
186
|
|
|
@@ -152,6 +191,19 @@ const serialize = ({ top, right, bottom, left }: PaddingSides): string => {
|
|
|
152
191
|
return `pt-${top} pr-${right} pb-${bottom} pl-${left}`
|
|
153
192
|
}
|
|
154
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Write the two sides of a two-sided block back. Which form depends on what is
|
|
196
|
+
* on screen rather than on the values: the one collapsed field writes `py-N` /
|
|
197
|
+
* `px-N`, the expanded pair writes `pt-N pb-N` / `pl-N pr-N` even while the two
|
|
198
|
+
* agree.
|
|
199
|
+
*/
|
|
200
|
+
const serializeAxis = (sides: PaddingSides, axis: PaddingAxis, expanded: boolean): string => {
|
|
201
|
+
const [first, second] = AXES[axis].sides
|
|
202
|
+
return expanded
|
|
203
|
+
? `${SIDE_PREFIX[first]}-${sides[first]} ${SIDE_PREFIX[second]}-${sides[second]}`
|
|
204
|
+
: `${AXES[axis].both}-${sides[first]}`
|
|
205
|
+
}
|
|
206
|
+
|
|
155
207
|
/**
|
|
156
208
|
* Turn a px value into a step: clamp to 0-384 and take the nearest one. A tie
|
|
157
209
|
* goes to the lower step, so 14 px becomes 12 and 60 px becomes 56; that way a
|
|
@@ -183,20 +235,27 @@ import PaddingIcon from './_PaddingIcon.vue'
|
|
|
183
235
|
|
|
184
236
|
defineOptions({ name: 'NexxtEditorPadding' })
|
|
185
237
|
|
|
186
|
-
const {
|
|
238
|
+
const {
|
|
239
|
+
label = 'Padding',
|
|
240
|
+
messages,
|
|
241
|
+
sides: offered = 'all',
|
|
242
|
+
} = defineProps<NexxtEditorPaddingProps>()
|
|
187
243
|
|
|
188
244
|
/** The padding, as the Tailwind class string to apply. */
|
|
189
245
|
const model = defineModel<string>({ required: true })
|
|
190
246
|
|
|
191
247
|
const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
|
|
192
248
|
|
|
193
|
-
|
|
249
|
+
/** The axis of a two-sided block, or `undefined` for one that edits all four sides. */
|
|
250
|
+
const axis = computed<PaddingAxis | undefined>(() => (offered === 'all' ? undefined : offered))
|
|
251
|
+
|
|
252
|
+
const values = ref<PaddingSides>(parse(model.value, axis.value))
|
|
194
253
|
|
|
195
254
|
// The model is the source of truth, so a change from outside re-reads it. The
|
|
196
255
|
// open state is deliberately left alone: collapsing is a view choice of the
|
|
197
256
|
// user, and a re-parse mid-edit should not fold the four fields away.
|
|
198
|
-
watch(model, (value) => {
|
|
199
|
-
|
|
257
|
+
watch([model, axis], ([value, current]) => {
|
|
258
|
+
values.value = parse(value, current)
|
|
200
259
|
})
|
|
201
260
|
|
|
202
261
|
/**
|
|
@@ -204,7 +263,7 @@ watch(model, (value) => {
|
|
|
204
263
|
* cannot be expressed with two fields; from then on the button owns it.
|
|
205
264
|
*/
|
|
206
265
|
const expanded = ref(
|
|
207
|
-
|
|
266
|
+
values.value.top !== values.value.bottom || values.value.left !== values.value.right,
|
|
208
267
|
)
|
|
209
268
|
|
|
210
269
|
interface PaddingField {
|
|
@@ -227,8 +286,22 @@ const singleSideField = (side: PaddingIconSide): PaddingField => ({
|
|
|
227
286
|
* The row that is always visible. Its two fields swap meaning the instant the
|
|
228
287
|
* toggle flips -- only the second row animates, so the block never jumps.
|
|
229
288
|
*/
|
|
230
|
-
const primaryFields = computed<PaddingField[]>(() =>
|
|
231
|
-
|
|
289
|
+
const primaryFields = computed<PaddingField[]>(() => {
|
|
290
|
+
if (axis.value) {
|
|
291
|
+
const [first, second] = AXES[axis.value].sides
|
|
292
|
+
return expanded.value
|
|
293
|
+
? [singleSideField(first), singleSideField(second)]
|
|
294
|
+
: [
|
|
295
|
+
{
|
|
296
|
+
key: axis.value,
|
|
297
|
+
targets: [first, second],
|
|
298
|
+
source: first,
|
|
299
|
+
label: copy.value[axis.value],
|
|
300
|
+
},
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return expanded.value
|
|
232
305
|
? [singleSideField('top'), singleSideField('right')]
|
|
233
306
|
: [
|
|
234
307
|
{
|
|
@@ -238,8 +311,8 @@ const primaryFields = computed<PaddingField[]>(() =>
|
|
|
238
311
|
label: copy.value.horizontal,
|
|
239
312
|
},
|
|
240
313
|
{ key: 'vertical', targets: ['top', 'bottom'], source: 'top', label: copy.value.vertical },
|
|
241
|
-
]
|
|
242
|
-
)
|
|
314
|
+
]
|
|
315
|
+
})
|
|
243
316
|
|
|
244
317
|
/** The row that slides open. Stays mounted so its height can be animated. */
|
|
245
318
|
const secondaryFields = computed<PaddingField[]>(() => [
|
|
@@ -257,20 +330,20 @@ const secondaryInert = computed(() => (expanded.value ? undefined : true))
|
|
|
257
330
|
// The fields speak px, not steps: `p-6` reads as 24. A 0 is shown as an empty
|
|
258
331
|
// field so the placeholder carries it, and an empty field commits back as a 0.
|
|
259
332
|
const display = (side: PaddingIconSide) =>
|
|
260
|
-
|
|
333
|
+
values.value[side] === 0 ? '' : String(pxOf(values.value[side]))
|
|
261
334
|
|
|
262
335
|
const apply = (next: PaddingSides) => {
|
|
263
336
|
// A change that changes nothing -- committing 30 where the value is already
|
|
264
337
|
// 28 px, arrowing up at the ceiling, closing a symmetrical block -- writes
|
|
265
338
|
// nothing either, so the editor does not see an edit that was not one.
|
|
266
|
-
if (ALL_SIDES.every((side) => next[side] ===
|
|
339
|
+
if (ALL_SIDES.every((side) => next[side] === values.value[side])) return
|
|
267
340
|
|
|
268
|
-
|
|
269
|
-
model.value = serialize(next)
|
|
341
|
+
values.value = next
|
|
342
|
+
model.value = axis.value ? serializeAxis(next, axis.value, expanded.value) : serialize(next)
|
|
270
343
|
}
|
|
271
344
|
|
|
272
345
|
const write = (field: PaddingField, step: number) => {
|
|
273
|
-
const next = { ...
|
|
346
|
+
const next = { ...values.value }
|
|
274
347
|
for (const side of field.targets) next[side] = step
|
|
275
348
|
|
|
276
349
|
apply(next)
|
|
@@ -281,7 +354,7 @@ const onCommit = (field: PaddingField, px: number) => write(field, snap(px))
|
|
|
281
354
|
|
|
282
355
|
/** One step along the list, so the arrow keys never stop on a missing token. */
|
|
283
356
|
const onStep = (field: PaddingField, direction: 1 | -1) => {
|
|
284
|
-
const index = EDITOR_PADDING_STEPS.findIndex(({ token }) => token ===
|
|
357
|
+
const index = EDITOR_PADDING_STEPS.findIndex(({ token }) => token === values.value[field.source])
|
|
285
358
|
// No wrapping: the ends of the list are the ends of the range.
|
|
286
359
|
const next = Math.min(Math.max(index + direction, 0), EDITOR_PADDING_STEPS.length - 1)
|
|
287
360
|
|
|
@@ -300,7 +373,13 @@ const onToggle = () => {
|
|
|
300
373
|
expanded.value = !expanded.value
|
|
301
374
|
if (expanded.value) return
|
|
302
375
|
|
|
303
|
-
|
|
376
|
+
if (axis.value) {
|
|
377
|
+
// The collapsed field reads the first side, so the second follows it.
|
|
378
|
+
const [first, second] = AXES[axis.value].sides
|
|
379
|
+
return apply({ ...values.value, [second]: values.value[first] })
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
apply({ ...values.value, bottom: values.value.top, left: values.value.right })
|
|
304
383
|
}
|
|
305
384
|
</script>
|
|
306
385
|
|
|
@@ -313,9 +392,14 @@ const onToggle = () => {
|
|
|
313
392
|
ones above them.
|
|
314
393
|
-->
|
|
315
394
|
<div class="grid grid-cols-[1fr_1fr_2.5rem] gap-2">
|
|
395
|
+
<!--
|
|
396
|
+
A two-sided block has one field when collapsed; it spans both field
|
|
397
|
+
columns so the toggle stays in the third.
|
|
398
|
+
-->
|
|
316
399
|
<PaddingField
|
|
317
400
|
v-for="field in primaryFields"
|
|
318
401
|
:key="field.key"
|
|
402
|
+
:class="{ 'col-span-2': primaryFields.length === 1 }"
|
|
319
403
|
:sides="field.targets"
|
|
320
404
|
:label="field.label"
|
|
321
405
|
:value="display(field.source)"
|
|
@@ -358,6 +442,7 @@ const onToggle = () => {
|
|
|
358
442
|
`inert` keeps the two hidden fields out of the tab order meanwhile.
|
|
359
443
|
-->
|
|
360
444
|
<div
|
|
445
|
+
v-if="!axis"
|
|
361
446
|
data-testid="editor-padding-secondary-row"
|
|
362
447
|
:inert="secondaryInert"
|
|
363
448
|
:class="[
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The strengths the editor offers, in the order they are shown. `token` is the
|
|
4
|
+
* class without its fixed `shadow-` prefix, so the class is `shadow-` plus the
|
|
5
|
+
* token. They are three steps of the theme's `--shadow-*` scale -- the ones the
|
|
6
|
+
* sidebar has room for as labelled options. "No shadow" is not a strength: it is
|
|
7
|
+
* the switch being off. The labels are not in here; they arrive through
|
|
8
|
+
* `messages`, keyed by token.
|
|
9
|
+
*/
|
|
10
|
+
export const EDITOR_SHADOW_OPTIONS = [{ token: 'sm' }, { token: 'md' }, { token: 'lg' }] as const
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The shadow strength as the editor needs it: the Tailwind class itself, so the
|
|
14
|
+
* consumer can put the model value straight on the element it styles.
|
|
15
|
+
*/
|
|
16
|
+
export type EditorShadowValue = `shadow-${(typeof EDITOR_SHADOW_OPTIONS)[number]['token']}`
|
|
17
|
+
|
|
18
|
+
/** Overridable copy of the strength control. Every key is optional. */
|
|
19
|
+
export interface EditorShadowMessages {
|
|
20
|
+
/** Label above the three strengths. Defaults to the Dutch `'Sterkte'`. */
|
|
21
|
+
strength?: string
|
|
22
|
+
/** Name of the `shadow-sm` option. Defaults to the Dutch `'Licht'`. */
|
|
23
|
+
sm?: string
|
|
24
|
+
/** Name of the `shadow-md` option. Defaults to the Dutch `'Gemiddeld'`. */
|
|
25
|
+
md?: string
|
|
26
|
+
/** Name of the `shadow-lg` option. Defaults to the Dutch `'Sterk'`. */
|
|
27
|
+
lg?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface NexxtEditorShadowProps {
|
|
31
|
+
/**
|
|
32
|
+
* Header text of the settings block. Defaults to the Dutch `'Schaduw'`; the
|
|
33
|
+
* library has no i18n layer, so a consumer in another language passes its own
|
|
34
|
+
* copy in.
|
|
35
|
+
*/
|
|
36
|
+
label?: string
|
|
37
|
+
/**
|
|
38
|
+
* Render a switch next to the label that collapses and expands the strengths.
|
|
39
|
+
* Defaults to `true`: a shadow is the kind of setting that is off as often as
|
|
40
|
+
* it is on, so turning it off is part of the control rather than an extra.
|
|
41
|
+
* The switch state is the `enabled` model; the component only reports it and
|
|
42
|
+
* leaves the strength alone, so how a consumer stores an element without a
|
|
43
|
+
* shadow is up to the consumer.
|
|
44
|
+
*/
|
|
45
|
+
switchable?: boolean
|
|
46
|
+
/**
|
|
47
|
+
* Copy of the strength control. Merged per key, so overriding one key keeps
|
|
48
|
+
* the defaults of the others. Copy is never translated inside the component.
|
|
49
|
+
*/
|
|
50
|
+
messages?: EditorShadowMessages
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_MESSAGES = {
|
|
54
|
+
strength: 'Sterkte',
|
|
55
|
+
sm: 'Licht',
|
|
56
|
+
md: 'Gemiddeld',
|
|
57
|
+
lg: 'Sterk',
|
|
58
|
+
} satisfies Required<EditorShadowMessages>
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<script lang="ts" setup>
|
|
62
|
+
import { computed } from 'vue'
|
|
63
|
+
import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
|
|
64
|
+
import NexxtToggleGroup from '../../../molecules/ToggleGroup/ToggleGroup.vue'
|
|
65
|
+
import type { NexxtToggleGroupOption } from '../../../molecules/ToggleGroup/ToggleGroup.vue'
|
|
66
|
+
|
|
67
|
+
defineOptions({ name: 'NexxtEditorShadow' })
|
|
68
|
+
|
|
69
|
+
const { label = 'Schaduw', switchable = true, messages } = defineProps<NexxtEditorShadowProps>()
|
|
70
|
+
|
|
71
|
+
/** The selected strength, as the Tailwind class to apply. */
|
|
72
|
+
const model = defineModel<EditorShadowValue>({ required: true })
|
|
73
|
+
|
|
74
|
+
/** Open state of the block. Only has an effect when `switchable` is `true`. */
|
|
75
|
+
const enabled = defineModel<boolean>('enabled', { default: true })
|
|
76
|
+
|
|
77
|
+
const copy = computed(() => ({ ...DEFAULT_MESSAGES, ...messages }))
|
|
78
|
+
|
|
79
|
+
const options = computed<NexxtToggleGroupOption[]>(() =>
|
|
80
|
+
EDITOR_SHADOW_OPTIONS.map(({ token }) => ({
|
|
81
|
+
value: `shadow-${token}`,
|
|
82
|
+
label: copy.value[token],
|
|
83
|
+
})),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
// ToggleGroup types its model as the open `string` of its options, so writing
|
|
87
|
+
// straight back into the narrower model is a type error. The setter is the one
|
|
88
|
+
// place that narrowing happens, and it is safe: the only values ToggleGroup can
|
|
89
|
+
// emit are the option values built above. A value that is not on the list -- a
|
|
90
|
+
// stored `shadow-xl` from before this component, say -- shows the first option
|
|
91
|
+
// instead of leaving every one of them unselected; the first change writes a
|
|
92
|
+
// valid class back.
|
|
93
|
+
const selected = computed({
|
|
94
|
+
get: () =>
|
|
95
|
+
options.value.some(({ value }) => value === model.value) ? model.value : options.value[0].value,
|
|
96
|
+
set: (value: string) => {
|
|
97
|
+
model.value = value as EditorShadowValue
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<template>
|
|
103
|
+
<NexxtEditorSettingsBlock v-model="enabled" :label="label" :switchable="switchable">
|
|
104
|
+
<NexxtToggleGroup v-model="selected" :label="copy.strength" :options="options" />
|
|
105
|
+
</NexxtEditorSettingsBlock>
|
|
106
|
+
</template>
|
|
@@ -218,7 +218,7 @@ watch(
|
|
|
218
218
|
|
|
219
219
|
<template>
|
|
220
220
|
<NexxtEditorSettingsBlock :label="label">
|
|
221
|
-
<NexxtToggleGroup v-model="mode" :options="options" />
|
|
221
|
+
<NexxtToggleGroup v-model="mode" :options="options" :aria-label="label" />
|
|
222
222
|
|
|
223
223
|
<!--
|
|
224
224
|
The field slides open under the choice it belongs to, the same 0fr/1fr
|
|
@@ -44,6 +44,14 @@ export interface NexxtEditorTextAlignProps {
|
|
|
44
44
|
* Copy is never translated inside the component.
|
|
45
45
|
*/
|
|
46
46
|
messages?: EditorTextAlignMessages
|
|
47
|
+
/**
|
|
48
|
+
* The options to offer, as a subset of the four. They are always shown in
|
|
49
|
+
* the order of `EDITOR_TEXT_ALIGN_OPTIONS`, whatever order this list is in.
|
|
50
|
+
* Defaults to all four. A consumer that writes alignment as something other
|
|
51
|
+
* than `text-*` -- auto margins, say -- passes the three it can translate and
|
|
52
|
+
* maps the value itself.
|
|
53
|
+
*/
|
|
54
|
+
options?: EditorTextAlignValue[]
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
const DEFAULT_MESSAGES = {
|
|
@@ -62,7 +70,11 @@ import type { NexxtToggleGroupOption } from '../../../molecules/ToggleGroup/Togg
|
|
|
62
70
|
|
|
63
71
|
defineOptions({ name: 'NexxtEditorTextAlign' })
|
|
64
72
|
|
|
65
|
-
const {
|
|
73
|
+
const {
|
|
74
|
+
label = 'Tekst uitlijning',
|
|
75
|
+
messages,
|
|
76
|
+
options: offered,
|
|
77
|
+
} = defineProps<NexxtEditorTextAlignProps>()
|
|
66
78
|
|
|
67
79
|
/** The selected text alignment, as the Tailwind class to apply. */
|
|
68
80
|
const model = defineModel<EditorTextAlignValue>({ required: true })
|
|
@@ -87,8 +99,12 @@ const ICONS: Record<
|
|
|
87
99
|
justify: 'align-justify',
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
// Filtered from the full list rather than mapped from `offered`, so the order is
|
|
103
|
+
// always the canonical one and a duplicate in `offered` cannot double a button.
|
|
90
104
|
const options = computed<NexxtToggleGroupOption[]>(() =>
|
|
91
|
-
EDITOR_TEXT_ALIGN_OPTIONS.
|
|
105
|
+
EDITOR_TEXT_ALIGN_OPTIONS.filter(
|
|
106
|
+
({ token }) => !offered || offered.includes(`text-${token}`),
|
|
107
|
+
).map(({ token }) => ({
|
|
92
108
|
value: `text-${token}`,
|
|
93
109
|
icon: ICONS[token],
|
|
94
110
|
ariaLabel: copy.value[token],
|
|
@@ -109,6 +125,6 @@ const selected = computed({
|
|
|
109
125
|
|
|
110
126
|
<template>
|
|
111
127
|
<NexxtEditorSettingsBlock :label="label">
|
|
112
|
-
<NexxtToggleGroup v-model="selected" :options="options" />
|
|
128
|
+
<NexxtToggleGroup v-model="selected" :options="options" :aria-label="label" />
|
|
113
129
|
</NexxtEditorSettingsBlock>
|
|
114
130
|
</template>
|