@nexxtmove/ui 1.44.0 → 1.46.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/EditorTextAlign/EditorTextAlign.vue.d.ts +8 -0
- package/dist/components/molecules/ToggleGroup/ToggleGroup.vue.d.ts +22 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +2207 -1874
- package/dist/nuxt.js +3 -0
- package/package.json +1 -1
- 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 +16 -2
- package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.vue +139 -0
- package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue +16 -2
- 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/EditorTextAlign/EditorTextAlign.vue +18 -2
- package/src/components/molecules/ToggleGroup/ToggleGroup.vue +30 -8
- package/src/components/molecules/Tree/Tree.vue +30 -10
- package/src/components.json +3 -0
- package/src/index.ts +22 -0
- package/tailwind/nexxtmove.tailwind.custom.css +6 -0
|
@@ -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 group is named explicitly. Both attributes fall through to
|
|
160
|
+
ToggleGroup's root.
|
|
161
|
+
-->
|
|
162
|
+
<NexxtToggleGroup v-model="selected" :options="options" role="group" :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="[
|
|
@@ -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],
|
|
@@ -36,6 +36,22 @@ const props = withDefaults(defineProps<NexxtToggleGroupProps>(), {
|
|
|
36
36
|
|
|
37
37
|
const model = defineModel<string>({ required: true })
|
|
38
38
|
|
|
39
|
+
defineSlots<{
|
|
40
|
+
/**
|
|
41
|
+
* Content of one option, replacing the default icon and label. Rendered
|
|
42
|
+
* twice per option -- once on the button and once in the clipped overlay
|
|
43
|
+
* that shows the active colour -- so it should draw in `currentColor` and
|
|
44
|
+
* carry no state or ids of its own. The button keeps its `ariaLabel` /
|
|
45
|
+
* `tooltip` naming, so content without text still needs `ariaLabel`.
|
|
46
|
+
*/
|
|
47
|
+
option?: (props: {
|
|
48
|
+
/** The option being rendered. */
|
|
49
|
+
option: NexxtToggleGroupOption
|
|
50
|
+
/** Whether this option is the selected value. */
|
|
51
|
+
selected: boolean
|
|
52
|
+
}) => unknown
|
|
53
|
+
}>()
|
|
54
|
+
|
|
39
55
|
const containerRef = useTemplateRef('containerRef')
|
|
40
56
|
const buttonRefs = ref<HTMLButtonElement[]>([])
|
|
41
57
|
const sliderStyle = ref({ width: '0px', transform: 'translateX(0px)' })
|
|
@@ -112,12 +128,14 @@ onMounted(async () => {
|
|
|
112
128
|
:ref="(el) => (buttonRefs[index] = el as HTMLButtonElement)"
|
|
113
129
|
type="button"
|
|
114
130
|
:aria-label="option.ariaLabel ?? (option.label ? undefined : option.tooltip)"
|
|
115
|
-
class="relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 whitespace-nowrap text-gray-
|
|
131
|
+
class="relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 whitespace-nowrap text-gray-700 hover:text-gray-900"
|
|
116
132
|
:class="{ 'flex-1': equalWidth }"
|
|
117
133
|
@click="model = option.value"
|
|
118
134
|
>
|
|
119
|
-
<
|
|
120
|
-
|
|
135
|
+
<slot name="option" :option="option" :selected="option.value === model">
|
|
136
|
+
<NexxtIcon v-if="option.icon" :name="option.icon" />
|
|
137
|
+
<span v-if="option.label" class="extra-small-semibold">{{ option.label }}</span>
|
|
138
|
+
</slot>
|
|
121
139
|
</button>
|
|
122
140
|
<template #tooltip>{{ option.tooltip }}</template>
|
|
123
141
|
</NexxtTooltip>
|
|
@@ -126,12 +144,14 @@ onMounted(async () => {
|
|
|
126
144
|
:ref="(el) => (buttonRefs[index] = el as HTMLButtonElement)"
|
|
127
145
|
type="button"
|
|
128
146
|
:aria-label="option.ariaLabel ?? (option.label ? undefined : option.tooltip)"
|
|
129
|
-
class="relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 whitespace-nowrap text-gray-
|
|
147
|
+
class="relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 whitespace-nowrap text-gray-700 hover:text-gray-900"
|
|
130
148
|
:class="{ 'flex-1': equalWidth }"
|
|
131
149
|
@click="model = option.value"
|
|
132
150
|
>
|
|
133
|
-
<
|
|
134
|
-
|
|
151
|
+
<slot name="option" :option="option" :selected="option.value === model">
|
|
152
|
+
<NexxtIcon v-if="option.icon" :name="option.icon" />
|
|
153
|
+
<span v-if="option.label" class="extra-small-semibold">{{ option.label }}</span>
|
|
154
|
+
</slot>
|
|
135
155
|
</button>
|
|
136
156
|
</template>
|
|
137
157
|
|
|
@@ -150,8 +170,10 @@ onMounted(async () => {
|
|
|
150
170
|
class="flex items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 whitespace-nowrap"
|
|
151
171
|
:class="[{ 'flex-1': equalWidth }, option.activeClass ?? 'text-cornflower-blue-600']"
|
|
152
172
|
>
|
|
153
|
-
<
|
|
154
|
-
|
|
173
|
+
<slot name="option" :option="option" :selected="option.value === model">
|
|
174
|
+
<NexxtIcon v-if="option.icon" :name="option.icon" />
|
|
175
|
+
<span v-if="option.label" class="extra-small-semibold">{{ option.label }}</span>
|
|
176
|
+
</slot>
|
|
155
177
|
</span>
|
|
156
178
|
</div>
|
|
157
179
|
</div>
|
|
@@ -80,7 +80,7 @@ const selected = defineModel<string | null>('selected', { default: null })
|
|
|
80
80
|
/** Ids of the nodes whose children are hidden. Tracking the collapsed ones makes "everything open" the default. */
|
|
81
81
|
const collapsed = defineModel<string[]>('collapsed', { default: () => [] })
|
|
82
82
|
|
|
83
|
-
const INDENT =
|
|
83
|
+
const INDENT = 8
|
|
84
84
|
|
|
85
85
|
const root = useTemplateRef<HTMLElement>('root')
|
|
86
86
|
const focusedId = ref<string | null>(null)
|
|
@@ -890,7 +890,7 @@ defineExpose({ focusNode })
|
|
|
890
890
|
<!-- The drop indicator: where the dragged node lands, at the depth it lands on. Out of
|
|
891
891
|
the accessibility tree and without `data-tree-id`, so it is no row to the keyboard,
|
|
892
892
|
the tab stop or the highlight. One element for the whole drag, that changes spots. -->
|
|
893
|
-
<div v-if="item.ghost" aria-hidden="true" class="mb-
|
|
893
|
+
<div v-if="item.ghost" aria-hidden="true" class="mb-1.5" data-tree-ghost>
|
|
894
894
|
<div
|
|
895
895
|
class="flex rounded-md bg-cornflower-blue-50 py-1 pr-2 pl-1 small-normal text-gray-700 outline-1 -outline-offset-1 outline-cornflower-blue-600 transition-[margin-left] duration-200 ease-in-out-strong outline-dashed motion-reduce:transition-none"
|
|
896
896
|
:style="{ marginLeft: `${item.depth * INDENT}px` }"
|
|
@@ -918,7 +918,7 @@ defineExpose({ focusNode })
|
|
|
918
918
|
:data-tree-id="item.row.node.id"
|
|
919
919
|
:data-tree-drop="collapsedTargetId === item.row.node.id ? 'inside' : undefined"
|
|
920
920
|
:draggable="item.row.draggable"
|
|
921
|
-
class="group mb-
|
|
921
|
+
class="group mb-1.5 flex items-center gap-1.5 overflow-x-clip rounded-md py-1 pr-2 small-normal transition-[color,background-color,outline-color,opacity] duration-150 select-none focus-visible:outline-2 focus-visible:outline-cornflower-blue-500 motion-reduce:transition-none"
|
|
922
922
|
:class="rowClasses(item.row)"
|
|
923
923
|
:style="{ paddingLeft: `${item.row.depth * INDENT + 4}px` }"
|
|
924
924
|
@click="onRowClick(item.row.node.id)"
|
|
@@ -955,19 +955,39 @@ defineExpose({ focusNode })
|
|
|
955
955
|
class="shrink-0 transition-colors duration-150 motion-reduce:transition-none"
|
|
956
956
|
:class="selected === item.row.node.id ? 'text-cornflower-blue-600' : 'text-gray-600'"
|
|
957
957
|
/>
|
|
958
|
-
<span :id="labelId(item.row.node.id)" class="truncate">{{
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
958
|
+
<span :id="labelId(item.row.node.id)" data-tree-label class="min-w-0 flex-1 truncate">{{
|
|
959
|
+
item.row.node.label
|
|
960
|
+
}}</span>
|
|
961
|
+
|
|
962
|
+
<!-- Only the selected row has actions: the track grows from 0 to their width and pushes
|
|
963
|
+
its label in, while their start, pinned to it, slides them in from the row's end.
|
|
964
|
+
The clip hides what has not slid in yet; the row's `overflow-x-clip` keeps them out
|
|
965
|
+
of the panel's scroll width meanwhile. Inert on every other row, so what cannot be
|
|
966
|
+
seen cannot be focused or read either; `undefined` rather than `false`, so no
|
|
967
|
+
`inert="false"` lands where the DOM has no such property. Pulled into the row's padding so a 24px
|
|
968
|
+
button leaves the row at 28px, and into the row's gap so a label without actions
|
|
969
|
+
runs to the row's end. Stopping here keeps the row's click and the tree's keys away
|
|
970
|
+
from the actions. -->
|
|
963
971
|
<div
|
|
964
972
|
v-if="slots['node-actions']"
|
|
965
973
|
data-tree-actions
|
|
966
|
-
class="-my-0.5 ml-
|
|
974
|
+
class="-my-0.5 -ml-1.5 grid shrink-0 transition-[grid-template-columns] duration-200 ease-in-out-strong [clip-path:inset(-0.5rem_-0.25rem)] motion-reduce:transition-none"
|
|
975
|
+
:class="
|
|
976
|
+
selected === item.row.node.id
|
|
977
|
+
? 'grid-cols-[minmax(0,1fr)]'
|
|
978
|
+
: 'grid-cols-[minmax(0,0fr)]'
|
|
979
|
+
"
|
|
980
|
+
:inert="selected !== item.row.node.id || undefined"
|
|
967
981
|
@click.stop
|
|
968
982
|
@keydown.stop
|
|
969
983
|
>
|
|
970
|
-
<
|
|
984
|
+
<div
|
|
985
|
+
data-tree-actions-content
|
|
986
|
+
class="flex w-max items-center pl-1.5 transition-opacity duration-150 ease-out motion-reduce:transition-none"
|
|
987
|
+
:class="selected === item.row.node.id ? 'opacity-100' : 'opacity-0'"
|
|
988
|
+
>
|
|
989
|
+
<NodeActions :node="item.row.node" :tabindex="tabindexOf(item.row.node.id)" />
|
|
990
|
+
</div>
|
|
971
991
|
</div>
|
|
972
992
|
</div>
|
|
973
993
|
</template>
|
package/src/components.json
CHANGED
|
@@ -74,10 +74,12 @@
|
|
|
74
74
|
"NexxtVideoPlayer": "components/organisms/VideoPlayer/VideoPlayer.vue",
|
|
75
75
|
"NexxtEditorDimensionField": "components/editor/atoms/EditorDimensionField/EditorDimensionField.vue",
|
|
76
76
|
"NexxtEditorSettingsBlock": "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
|
|
77
|
+
"NexxtEditorSettingsGroup": "components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.vue",
|
|
77
78
|
"NexxtEditorAlignItems": "components/editor/molecules/EditorAlignItems/EditorAlignItems.vue",
|
|
78
79
|
"NexxtEditorBackgroundSize": "components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue",
|
|
79
80
|
"NexxtEditorBorder": "components/editor/molecules/EditorBorder/EditorBorder.vue",
|
|
80
81
|
"NexxtEditorColor": "components/editor/molecules/EditorColor/EditorColor.vue",
|
|
82
|
+
"NexxtEditorDashPattern": "components/editor/molecules/EditorDashPattern/EditorDashPattern.vue",
|
|
81
83
|
"NexxtEditorFlexDirection": "components/editor/molecules/EditorFlexDirection/EditorFlexDirection.vue",
|
|
82
84
|
"NexxtEditorFontSize": "components/editor/molecules/EditorFontSize/EditorFontSize.vue",
|
|
83
85
|
"NexxtEditorGap": "components/editor/molecules/EditorGap/EditorGap.vue",
|
|
@@ -85,6 +87,7 @@
|
|
|
85
87
|
"NexxtEditorJustifyContent": "components/editor/molecules/EditorJustifyContent/EditorJustifyContent.vue",
|
|
86
88
|
"NexxtEditorLetterSpacing": "components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.vue",
|
|
87
89
|
"NexxtEditorLineHeight": "components/editor/molecules/EditorLineHeight/EditorLineHeight.vue",
|
|
90
|
+
"NexxtEditorLineStyle": "components/editor/molecules/EditorLineStyle/EditorLineStyle.vue",
|
|
88
91
|
"NexxtEditorMaxWidth": "components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue",
|
|
89
92
|
"NexxtEditorPadding": "components/editor/molecules/EditorPadding/EditorPadding.vue",
|
|
90
93
|
"NexxtEditorRadius": "components/editor/molecules/EditorRadius/EditorRadius.vue",
|