@nexxtmove/ui 1.35.0 → 1.36.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.35.0",
4
+ "version": "1.36.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -7,18 +7,47 @@
7
7
  */
8
8
  export type EditorColorProperty = 'bg' | 'text'
9
9
 
10
+ const ROLES = ['main', 'accent', 'background', 'heading', 'text'] as const
11
+
10
12
  /**
11
- * The colour as the editor needs it: a Tailwind arbitrary-value class such as
12
- * `'bg-[#1777EA]'`, so the consumer can put the model value straight on the
13
- * element it styles.
13
+ * A role of the customer's theme. A colour bound to a role follows the theme,
14
+ * where a literal colour stays what it was picked as.
14
15
  */
15
- export type EditorColorValue = `${EditorColorProperty}-[#${string}]`
16
+ export type EditorColorRole = (typeof ROLES)[number]
17
+
18
+ /** A literal colour: a Tailwind arbitrary-value class such as `'bg-[#1777EA]'`. */
19
+ export type EditorColorLiteralValue = `${EditorColorProperty}-[#${string}]`
20
+
21
+ /** A colour bound to a theme role: a token class such as `'bg-theme-main'`. */
22
+ export type EditorColorRoleValue = `${EditorColorProperty}-theme-${EditorColorRole}`
23
+
24
+ /**
25
+ * The colour as the editor needs it: the class to apply, so the consumer can
26
+ * put the model value straight on the element it styles. Either a literal
27
+ * colour or a theme role.
28
+ */
29
+ export type EditorColorValue = EditorColorLiteralValue | EditorColorRoleValue
30
+
31
+ export interface EditorColorThemeRole {
32
+ /** The role the class is written for: `'main'` becomes `bg-theme-main`. */
33
+ role: EditorColorRole
34
+ /** Name of the role as the user reads it, in the swatch's tooltip and in the trigger. */
35
+ label: string
36
+ /**
37
+ * The role's current colour as a 6- or 8-digit hex. Only used to paint the
38
+ * swatch: the library ships no CSS for the role classes, and the hex never
39
+ * reaches the model.
40
+ */
41
+ hex: string
42
+ }
16
43
 
17
44
  export interface EditorColorMessages {
18
45
  /** Text shown while no colour is set. Defaults to `'Kies kleur...'`. */
19
46
  placeholder?: string
20
47
  /** Label shown above reusable colours. */
21
48
  suggestionsLabel?: string
49
+ /** Label shown above the theme roles. Defaults to `'Huisstijl'`. */
50
+ themeLabel?: string
22
51
  }
23
52
 
24
53
  export interface NexxtEditorColorProps {
@@ -47,26 +76,45 @@ export interface NexxtEditorColorProps {
47
76
  messages?: EditorColorMessages
48
77
  /** Hex colours available inside the picker panel as one-click suggestions. */
49
78
  suggestions?: string[]
79
+ /**
80
+ * Theme roles offered above the suggestions. Picking one writes the role
81
+ * class for the current `property` instead of a literal colour. Which roles
82
+ * are offered is up to the consumer; an entry whose `hex` is not a 6- or
83
+ * 8-digit hex is left out. Without roles the section is not rendered.
84
+ */
85
+ themeRoles?: EditorColorThemeRole[]
50
86
  }
51
87
 
52
88
  const DEFAULT_MESSAGES = {
53
89
  placeholder: 'Kies kleur...',
54
90
  suggestionsLabel: 'Gebruikte kleuren',
91
+ themeLabel: 'Huisstijl',
55
92
  } satisfies Required<EditorColorMessages>
56
93
 
57
94
  /**
58
- * A full model value: one of the two prefixes, then a 6- or 8-digit hex between
95
+ * A literal model value: one of the two prefixes, then a 6- or 8-digit hex between
59
96
  * brackets. A `border-[…]` class is therefore read as unparseable, the same as
60
97
  * any other class this component does not own. The
61
98
  * alpha pair is accepted on read so a stored `#1777EA80` survives a round trip,
62
99
  * even though the picker itself is opened without an alpha channel here.
63
100
  */
64
101
  const VALUE_PATTERN = /^(bg|text)-\[(#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?)\]$/
102
+
103
+ /**
104
+ * A role class: one of the two prefixes, then `theme-` and one of the five
105
+ * roles. Either prefix takes every role. A tint such as `bg-theme-main/10` is
106
+ * not a value this component writes, so it is read as unparseable.
107
+ */
108
+ const ROLE_PATTERN = new RegExp(`^(?:bg|text)-theme-(${ROLES.join('|')})$`)
109
+
110
+ /** The picker throws on a colour it cannot read, so a role's hex is checked first. */
111
+ const ROLE_HEX_PATTERN = /^#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?$/
65
112
  </script>
66
113
 
67
114
  <script lang="ts" setup>
68
115
  import { computed, watch } from 'vue'
69
116
  import NexxtEditorSettingsBlock from '../../atoms/EditorSettingsBlock/EditorSettingsBlock.vue'
117
+ import NexxtTooltip from '../../../atoms/Tooltip/Tooltip.vue'
70
118
  import NexxtColorPicker from '../../../organisms/ColorPicker/ColorPicker.vue'
71
119
 
72
120
  defineOptions({ name: 'NexxtEditorColor' })
@@ -77,17 +125,21 @@ const {
77
125
  property = 'bg',
78
126
  messages,
79
127
  suggestions = [],
128
+ themeRoles = [],
80
129
  } = defineProps<NexxtEditorColorProps>()
81
130
 
82
131
  /**
83
132
  * The chosen colour, as the Tailwind class to apply.
84
133
  *
85
134
  * Beware: Tailwind only generates an arbitrary class for text it finds in the
86
- * source at build time. A value that arrives at runtime -- from a database,
87
- * say -- therefore gets no CSS of its own; the consumer either translates it
88
- * into an inline style or safelists the classes it can produce. The hex is
89
- * always parseable out of the string, which is what makes that translation
90
- * possible.
135
+ * source at build time. A literal value that arrives at runtime -- from a
136
+ * database, say -- therefore gets no CSS of its own; the consumer either
137
+ * translates it into an inline style or safelists the classes it can produce.
138
+ * The hex is always parseable out of a literal value, which is what makes that
139
+ * translation possible.
140
+ *
141
+ * A role class is a finite list, so it is static CSS -- but the consumer's:
142
+ * this library defines no `theme-*` colours.
91
143
  */
92
144
  const model = defineModel<EditorColorValue>({ required: true })
93
145
 
@@ -109,12 +161,36 @@ const hex = computed(() => {
109
161
  return captured && isHex(captured) ? captured : ''
110
162
  })
111
163
 
164
+ /** The role out of the model, or `undefined` when it is not a role class. */
165
+ const storedRole = computed(() => {
166
+ const captured = ROLE_PATTERN.exec(model.value)?.[1]
167
+ return ROLES.find((role) => role === captured)
168
+ })
169
+
170
+ const offeredRoles = computed(() => themeRoles.filter((entry) => ROLE_HEX_PATTERN.test(entry.hex)))
171
+
172
+ /**
173
+ * The stored role as the consumer offers it. Only a role class selects a role:
174
+ * a literal colour that happens to equal a role's hex stays a literal.
175
+ */
176
+ const activeRole = computed(() => offeredRoles.value.find(({ role }) => role === storedRole.value))
177
+
178
+ /**
179
+ * Picking the role's own colour -- from the suggestions, or typed -- is how a
180
+ * colour is detached from the theme without changing how it looks. The picker
181
+ * reports nothing when it is told to pick the string it already has, so a role's
182
+ * hex is handed over in its 8-digit form: with `show-alpha` off the picker never
183
+ * reports an alpha pair, which makes every pick a change.
184
+ */
185
+ const pickerHex = (roleHex: string) => (roleHex.length === 7 ? `${roleHex}ff` : roleHex)
186
+
112
187
  // An unparseable value -- empty, a token class such as `bg-red-500`, or copy
113
188
  // from before this component -- leaves the picker empty and its placeholder
114
189
  // visible, rather than being reported as a problem: the first pick writes a
115
- // valid class back.
190
+ // valid class back. A role the consumer does not offer is shown the same way,
191
+ // because there is no label or hex to show it with; it is not written back.
116
192
  const picked = computed({
117
- get: () => hex.value,
193
+ get: () => (activeRole.value ? pickerHex(activeRole.value.hex) : hex.value),
118
194
  // The picker clears its own value in no flow it offers, but an empty string
119
195
  // would still wipe a perfectly good colour, so it is dropped rather than
120
196
  // written through. The picker keeps rendering the model's colour.
@@ -123,13 +199,18 @@ const picked = computed({
123
199
  },
124
200
  })
125
201
 
202
+ const pickRole = (role: EditorColorRole) => {
203
+ model.value = `${property}-theme-${role}`
204
+ }
205
+
126
206
  // `property` is a prop, so the parent can flip a background setting into a
127
207
  // text-colour one while a value is already set. Rewriting the prefix here is
128
208
  // what keeps the stored class and the prop from drifting apart.
129
209
  watch(
130
210
  () => property,
131
211
  (next) => {
132
- if (hex.value) model.value = `${next}-[${hex.value}]`
212
+ if (storedRole.value) model.value = `${next}-theme-${storedRole.value}`
213
+ else if (hex.value) model.value = `${next}-[${hex.value}]`
133
214
  },
134
215
  )
135
216
  </script>
@@ -157,6 +238,42 @@ watch(
157
238
  :placeholder="copy.placeholder"
158
239
  :suggestions="{ hex: suggestions }"
159
240
  :suggestions-label="copy.suggestionsLabel"
160
- />
241
+ :value-label="activeRole?.label"
242
+ >
243
+ <template v-if="offeredRoles.length" #presets>
244
+ <div class="flex flex-col gap-1">
245
+ <span class="extra-small-normal text-gray-500">{{ copy.themeLabel }}</span>
246
+ <!--
247
+ Same swatches and row as the picker's suggestions. Two roles often
248
+ share a hex (heading and text), so the tooltip is what tells them
249
+ apart; it repeats the accessible name rather than adding copy.
250
+ -->
251
+ <div class="flex flex-wrap gap-1.5">
252
+ <NexxtTooltip
253
+ v-for="{ role, label: roleLabel, hex: roleHex } in offeredRoles"
254
+ :key="role"
255
+ disable-closing-trigger
256
+ >
257
+ <button
258
+ type="button"
259
+ :aria-label="roleLabel"
260
+ :aria-pressed="role === activeRole?.role"
261
+ class="cursor-pointer rounded-md p-0.5 ring-gray-200 transition-shadow hover:ring-2 hover:ring-cornflower-blue-400 focus-visible:ring-2 focus-visible:ring-cornflower-blue-500 focus-visible:outline-none aria-pressed:ring-2 aria-pressed:ring-cornflower-blue-500"
262
+ @click="pickRole(role)"
263
+ >
264
+ <span
265
+ aria-hidden="true"
266
+ class="block size-6 rounded-sm shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]"
267
+ :style="{ backgroundColor: roleHex }"
268
+ />
269
+ </button>
270
+ <template #tooltip>
271
+ <span class="small-normal text-gray-900">{{ roleLabel }}</span>
272
+ </template>
273
+ </NexxtTooltip>
274
+ </div>
275
+ </div>
276
+ </template>
277
+ </NexxtColorPicker>
161
278
  </NexxtEditorSettingsBlock>
162
279
  </template>
@@ -35,10 +35,20 @@ export interface NexxtColorPickerProps {
35
35
  suggestions?: ColorSuggestions
36
36
  /** Accessible and visible label for the suggestions. */
37
37
  suggestionsLabel?: string
38
+ /**
39
+ * Text shown in the trigger in place of the formatted colour, for a value that has a name of
40
+ * its own, such as a theme role. Only shown while a colour is set.
41
+ */
42
+ valueLabel?: string
38
43
  }
39
44
 
40
45
  defineOptions({ name: 'NexxtColorPicker' })
41
46
 
47
+ defineSlots<{
48
+ /** Named colours a wrapper offers next to the free picker; rendered above the suggestions. */
49
+ presets?(): unknown
50
+ }>()
51
+
42
52
  const PICKER_ID = 'color-picker-' + Math.random().toString(36).slice(2, 10)
43
53
 
44
54
  const props = withDefaults(defineProps<NexxtColorPickerProps>(), {
@@ -84,7 +94,9 @@ const hexInputError = ref('')
84
94
  const channelInputErrors = ref<Partial<Record<ColorChannel, string>>>({})
85
95
 
86
96
  const hexColor = computed(() => colorToString(colorObj.value, 'hex'))
87
- const displayValue = computed(() => (model.value ? fmt(colorObj.value, activeFormat.value) : ''))
97
+ const displayValue = computed(() =>
98
+ model.value ? props.valueLabel || fmt(colorObj.value, activeFormat.value) : '',
99
+ )
88
100
  const colorSuggestions = computed(() => {
89
101
  const values = new Set<string>()
90
102
 
@@ -443,6 +455,7 @@ watch(
443
455
  </ColorFieldRoot>
444
456
  </div>
445
457
  </div>
458
+ <slot name="presets" />
446
459
  <div v-if="colorSuggestions.length" class="flex flex-col gap-1">
447
460
  <span class="extra-small-normal text-gray-500">{{ suggestionsLabel }}</span>
448
461
  <div class="flex flex-wrap gap-1.5">
package/src/index.ts CHANGED
@@ -49,8 +49,12 @@ export type {
49
49
  EditorGapValue,
50
50
  } from './components/editor/molecules/EditorGap/EditorGap.vue'
51
51
  export type {
52
+ EditorColorLiteralValue,
52
53
  EditorColorMessages,
53
54
  EditorColorProperty,
55
+ EditorColorRole,
56
+ EditorColorRoleValue,
57
+ EditorColorThemeRole,
54
58
  EditorColorValue,
55
59
  } from './components/editor/molecules/EditorColor/EditorColor.vue'
56
60
  export type {