@nexxtmove/ui 2.7.0 → 2.8.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.
@@ -74,11 +74,13 @@ export interface NexxtEditorColorProps {
74
74
  /**
75
75
  * Offer an opacity slider in the picker. A colour picked below full opacity is
76
76
  * written as an 8-digit hex (`bg-[#1777ea80]`); a fully opaque one keeps the
77
- * 6-digit form. The slider is hidden while a theme role is selected: a role
78
- * class carries no opacity. Off by default, in which case a stored 8-digit hex
79
- * is shown opaque and only replaced once a new colour is picked. Turning it off
80
- * while a translucent colour is set does write at once: the picker drops the
81
- * alpha and the model gets the opaque 6-digit hex.
77
+ * 6-digit form. While a theme role is selected the slider stands at 100%, as a
78
+ * role class carries no opacity; moving it writes the role's hex with that
79
+ * opacity, so the role is no longer selected. The same goes for a role stop of
80
+ * a gradient, which without `showAlpha` stays opaque. Off by default, in which
81
+ * case a stored 8-digit hex is shown opaque and only replaced once a new colour
82
+ * is picked. Turning it off while a translucent colour is set does write at
83
+ * once: the picker drops the alpha and the model gets the opaque 6-digit hex.
82
84
  */
83
85
  showAlpha?: boolean;
84
86
  /**
@@ -26,7 +26,10 @@ export interface ColorFieldProps<P extends string> {
26
26
  suggestions?: string[];
27
27
  /** Theme roles offered above the suggestions; an entry with an unreadable hex is left out. */
28
28
  themeRoles?: EditorColorThemeRole[];
29
- /** Offer an opacity slider, hidden while a theme role is selected. */
29
+ /**
30
+ * Offer an opacity slider, also while a theme role is selected: it then stands
31
+ * at 100%, and moving it turns the role into its hex with that opacity.
32
+ */
30
33
  showAlpha?: boolean;
31
34
  /**
32
35
  * The fills offered: `['solid']` (the default), `['solid', 'gradient']` or
@@ -16,7 +16,9 @@ export interface NexxtColorPickerProps {
16
16
  /**
17
17
  * Offer an opacity slider and accept colours below full opacity. Turning it
18
18
  * off while the colour is translucent writes the opaque colour to the model
19
- * at once.
19
+ * at once. The gradient mode always has the slider; without `showAlpha` it is
20
+ * locked at 100% for a stop bound to a preset, with it a change unbinds the
21
+ * stop from its preset.
20
22
  */
21
23
  showAlpha?: boolean;
22
24
  formats?: ColorFormat[];
package/dist/index.js CHANGED
@@ -2872,7 +2872,7 @@ var vn = { funda: { render: _n } }, $ = /* @__PURE__ */ p({
2872
2872
  set: (e) => {
2873
2873
  O.value = e === "gradient" ? "gradient" : "solid";
2874
2874
  }
2875
- }), oe = i(() => T.showAlpha || R.value), se = i(() => R.value && !!V.selected.value?.preset), ce = i(() => `flex flex-col ${R.value ? "gap-3" : "gap-4"} rounded-xl bg-white p-4 ring ring-gray-200`), le = (e, t) => {
2875
+ }), oe = i(() => T.showAlpha || R.value), se = i(() => R.value && !T.showAlpha && !!V.selected.value?.preset), ce = i(() => `flex flex-col ${R.value ? "gap-3" : "gap-4"} rounded-xl bg-white p-4 ring ring-gray-200`), le = (e, t) => {
2876
2876
  V.setStopColor(t.slice(0, 7), e);
2877
2877
  }, ue = () => {
2878
2878
  W.value = null, V.resetSession(), u("close");
@@ -7940,19 +7940,19 @@ var Ol = /* @__PURE__ */ p({
7940
7940
  }),
7941
7941
  emits: ["update:modelValue"],
7942
7942
  setup(e) {
7943
- let t = B(e, "modelValue"), n = i(() => Mt(t.value, e.accepts ?? [e.property])), r = i(() => n.value && "hex" in n.value ? n.value.hex : ""), o = i(() => n.value && "role" in n.value ? n.value.role : void 0), s = i(() => e.themeRoles.filter((e) => At.test(e.hex))), c = i(() => s.value.find(({ role: e }) => e === o.value)), u = (e) => `${e.slice(0, 7)}ff`, d = i(() => e.showAlpha && !c.value), p = i({
7943
+ let t = B(e, "modelValue"), n = i(() => Mt(t.value, e.accepts ?? [e.property])), r = i(() => n.value && "hex" in n.value ? n.value.hex : ""), o = i(() => n.value && "role" in n.value ? n.value.role : void 0), s = i(() => e.themeRoles.filter((e) => At.test(e.hex))), c = i(() => s.value.find(({ role: e }) => e === o.value)), u = (e) => `${e.slice(0, 7)}ff`, d = i({
7944
7944
  get: () => c.value ? u(c.value.hex) : r.value,
7945
7945
  set: (n) => {
7946
7946
  let r = n.replace(Jl, "$1");
7947
7947
  jt(r) && (t.value = `${e.property}-[${r}]`);
7948
7948
  }
7949
- }), m = (n) => {
7949
+ }), p = (n) => {
7950
7950
  t.value = `${e.property}-theme-${n}`;
7951
- }, h = i(() => e.modes.includes("gradient") && e.property === "bg" ? e.modes.includes("solid") ? ["solid", "gradient"] : ["gradient"] : ["solid"]), g = i(() => h.value.includes("gradient")), _ = i(() => !h.value.includes("solid")), v = i(() => g.value ? un(t.value) : null), y = (t) => pn(t) ? "gradient" : Mt(t, e.accepts ?? [e.property]) ? "solid" : null, b = A(g.value ? y(t.value) ?? "solid" : "solid");
7952
- K(() => [t.value, g.value], ([e, t]) => {
7953
- t ? b.value = y(e) ?? b.value : b.value = "solid";
7951
+ }, m = i(() => e.modes.includes("gradient") && e.property === "bg" ? e.modes.includes("solid") ? ["solid", "gradient"] : ["gradient"] : ["solid"]), h = i(() => m.value.includes("gradient")), g = i(() => !m.value.includes("solid")), _ = i(() => h.value ? un(t.value) : null), v = (t) => pn(t) ? "gradient" : Mt(t, e.accepts ?? [e.property]) ? "solid" : null, y = A(h.value ? v(t.value) ?? "solid" : "solid");
7952
+ K(() => [t.value, h.value], ([e, t]) => {
7953
+ t ? y.value = v(e) ?? y.value : y.value = "solid";
7954
7954
  });
7955
- let x = i(() => g.value ? _.value ? "gradient" : b.value : "solid"), S = A(0), C = A(null), w = (e) => s.value.find((t) => t.role === e), T = (e) => {
7955
+ let b = i(() => h.value ? g.value ? "gradient" : y.value : "solid"), x = A(0), S = A(null), C = (e) => s.value.find((t) => t.role === e), w = (e) => {
7956
7956
  if (!e) return;
7957
7957
  let t = [];
7958
7958
  for (let { color: n, position: r } of e.stops) {
@@ -7963,7 +7963,7 @@ var Ol = /* @__PURE__ */ p({
7963
7963
  });
7964
7964
  continue;
7965
7965
  }
7966
- let e = w(n.role);
7966
+ let e = C(n.role);
7967
7967
  if (!e) return;
7968
7968
  t.push({
7969
7969
  color: e.hex.slice(0, 7).toLowerCase(),
@@ -7981,13 +7981,13 @@ var Ol = /* @__PURE__ */ p({
7981
7981
  centerY: e.type === "radial" ? ni(e.y) : 50,
7982
7982
  stops: t
7983
7983
  };
7984
- }, E = i(() => T(v.value)), O = i(() => _.value && g.value && !E.value ? T(un(M())) : void 0), k = (e, t) => {
7984
+ }, T = i(() => w(_.value)), E = i(() => g.value && h.value && !T.value ? w(un(j())) : void 0), O = (e, t) => {
7985
7985
  let n = kt.find((e) => e === t);
7986
7986
  return n ? { role: n } : { hex: ln(jt(e) ? e : `#${e}`) };
7987
- }, j = (e) => {
7987
+ }, k = (e) => {
7988
7988
  if (!e) return;
7989
7989
  let n = e.stops.map(({ color: e, position: t, preset: n }) => ({
7990
- color: k(e, n?.key),
7990
+ color: O(e, n?.key),
7991
7991
  position: t
7992
7992
  }));
7993
7993
  t.value = fn(e.type === "linear" ? {
@@ -8000,7 +8000,7 @@ var Ol = /* @__PURE__ */ p({
8000
8000
  y: e.centerY,
8001
8001
  stops: n
8002
8002
  });
8003
- }, M = () => {
8003
+ }, j = () => {
8004
8004
  let e = c.value, t = r.value;
8005
8005
  if (e) {
8006
8006
  let t = e.hex.slice(0, 7).toLowerCase();
@@ -8011,75 +8011,75 @@ var Ol = /* @__PURE__ */ p({
8011
8011
  return `bg-linear-180 from-[${e}] to-[${e.slice(0, 7)}00]`;
8012
8012
  }
8013
8013
  return ql;
8014
- }, N = (t) => {
8014
+ }, M = (t) => {
8015
8015
  let n = ln(t), r = n.slice(0, 7);
8016
8016
  return e.showAlpha || !jt(r) ? n : r;
8017
- }, ee = (e) => e.stops.every(({ color: e }) => "hex" in e || !!w(e.role)), P = (e, t) => {
8018
- let n = e.stops[S.value] ?? e.stops[0];
8019
- return n ? "role" in n.color ? `${t}-theme-${n.color.role}` : `${t}-[${N(n.color.hex)}]` : null;
8020
- }, F = (n) => {
8021
- if (n === x.value || _.value) return;
8017
+ }, N = (e) => e.stops.every(({ color: e }) => "hex" in e || !!C(e.role)), ee = (e, t) => {
8018
+ let n = e.stops[x.value] ?? e.stops[0];
8019
+ return n ? "role" in n.color ? `${t}-theme-${n.color.role}` : `${t}-[${M(n.color.hex)}]` : null;
8020
+ }, P = (n) => {
8021
+ if (n === b.value || g.value) return;
8022
8022
  if (n === "gradient") {
8023
- b.value = "gradient";
8024
- let e = C.value;
8025
- S.value = e?.stop ?? 0, t.value = e?.value ?? M();
8023
+ y.value = "gradient";
8024
+ let e = S.value;
8025
+ x.value = e?.stop ?? 0, t.value = e?.value ?? j();
8026
8026
  return;
8027
8027
  }
8028
- b.value = "solid";
8029
- let r = E.value ? v.value : null;
8028
+ y.value = "solid";
8029
+ let r = T.value ? _.value : null;
8030
8030
  if (!r) return;
8031
- C.value = {
8031
+ S.value = {
8032
8032
  value: fn(r),
8033
- stop: S.value
8033
+ stop: x.value
8034
8034
  };
8035
- let i = P(r, e.property);
8035
+ let i = ee(r, e.property);
8036
8036
  i && (t.value = i);
8037
- }, I = (e, t) => {
8038
- x.value === "gradient" ? t({
8037
+ }, F = (e, t) => {
8038
+ b.value === "gradient" ? t({
8039
8039
  key: e.role,
8040
8040
  label: e.label
8041
- }, e.hex) : m(e.role);
8042
- }, L = (e, t) => x.value === "gradient" ? e === t : e === c.value?.role, R = () => {
8043
- C.value = null;
8041
+ }, e.hex) : p(e.role);
8042
+ }, I = (e, t) => b.value === "gradient" ? e === t : e === c.value?.role, L = () => {
8043
+ S.value = null;
8044
8044
  };
8045
8045
  return K(() => e.property, (e) => {
8046
8046
  if (o.value) t.value = `${e}-theme-${o.value}`;
8047
8047
  else if (r.value) t.value = `${e}-[${r.value}]`;
8048
8048
  else if (e !== "bg") {
8049
- let n = un(t.value), r = n && ee(n) ? P(n, e) : null;
8049
+ let n = un(t.value), r = n && N(n) ? ee(n, e) : null;
8050
8050
  r && (t.value = r);
8051
8051
  }
8052
8052
  }), (t, n) => (D(), a(la, {
8053
- modelValue: p.value,
8054
- "onUpdate:modelValue": n[0] ||= (e) => p.value = e,
8055
- "selected-stop": S.value,
8056
- "onUpdate:selectedStop": n[1] ||= (e) => S.value = e,
8057
- modes: h.value,
8053
+ modelValue: d.value,
8054
+ "onUpdate:modelValue": n[0] ||= (e) => d.value = e,
8055
+ "selected-stop": x.value,
8056
+ "onUpdate:selectedStop": n[1] ||= (e) => x.value = e,
8057
+ modes: m.value,
8058
8058
  "gradient-messages": e.gradientMessages,
8059
- mode: x.value,
8060
- "gradient-value": E.value,
8061
- "gradient-start": O.value,
8059
+ mode: b.value,
8060
+ "gradient-value": T.value,
8061
+ "gradient-start": E.value,
8062
8062
  "full-width": "",
8063
8063
  "match-width": "",
8064
8064
  label: e.label,
8065
8065
  describedby: e.describedby,
8066
8066
  formats: ["hex"],
8067
- "show-alpha": d.value,
8067
+ "show-alpha": e.showAlpha,
8068
8068
  placeholder: e.placeholder,
8069
8069
  suggestions: { hex: e.suggestions },
8070
8070
  "suggestions-label": e.suggestionsLabel,
8071
8071
  "alpha-label": e.alphaLabel,
8072
8072
  "value-label": c.value?.label,
8073
- "onUpdate:mode": F,
8074
- "onUpdate:gradientValue": j,
8075
- onClose: R
8073
+ "onUpdate:mode": P,
8074
+ "onUpdate:gradientValue": k,
8075
+ onClose: L
8076
8076
  }, l({ _: 2 }, [s.value.length ? {
8077
8077
  name: "presets",
8078
8078
  fn: q(({ preset: t, selectPreset: n }) => [f(ha, {
8079
8079
  roles: s.value,
8080
8080
  label: e.themeLabel,
8081
- "is-pressed": (e) => L(e.role, t),
8082
- onPick: (e) => I(e, n)
8081
+ "is-pressed": (e) => I(e.role, t),
8082
+ onPick: (e) => F(e, n)
8083
8083
  }, null, 8, [
8084
8084
  "roles",
8085
8085
  "label",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "2.7.0",
4
+ "version": "2.8.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -215,16 +215,23 @@ export const ThemeRolesWithSuggestions: Story = {
215
215
  }
216
216
 
217
217
  /**
218
- * With `showAlpha` the picker offers an opacity slider. Below full opacity the
219
- * class carries an 8-digit hex; a theme role has no opacity, so picking one
220
- * hides the slider.
218
+ * With `showAlpha` the picker offers an opacity slider, also for a theme role.
219
+ * A role has no opacity, so its slider stands at 100%; moving it writes the
220
+ * role's hex with an 8-digit alpha, and the role is no longer selected. Picking
221
+ * the role again makes it opaque. The suggestions are those of
222
+ * `ThemeRolesWithSuggestions`; two of them share the hex of a role, and picking
223
+ * one writes that hex as a literal, so the role is no longer selected.
221
224
  */
222
225
  export const WithAlpha: Story = {
223
- args: { showAlpha: true, themeRoles: THEME_ROLES },
226
+ args: {
227
+ showAlpha: true,
228
+ themeRoles: THEME_ROLES,
229
+ suggestions: ['#0F766E', '#F0F4F8', '#1F2937'],
230
+ },
224
231
  render: (args) => ({
225
232
  components: { EditorColor },
226
233
  setup() {
227
- const color = ref<EditorColorValue>('bg-[#1777EA80]')
234
+ const color = ref<EditorColorValue>('bg-theme-main')
228
235
  return { args, color }
229
236
  },
230
237
  template: `
@@ -241,19 +248,37 @@ export const WithAlpha: Story = {
241
248
 
242
249
  // The slider is named by its visible label and reads the opacity out as a percentage.
243
250
  const alpha = await canvas.findByRole('slider', { name: 'Dekking' })
244
- expect(alpha).toHaveAttribute('aria-valuetext', '50%')
251
+ expect(alpha).toHaveAttribute('aria-valuetext', '100%')
252
+ expect(canvas.getByRole('button', { name: 'Hoofdkleur' })).toHaveAttribute(
253
+ 'aria-pressed',
254
+ 'true',
255
+ )
245
256
 
257
+ // Opening wrote nothing, and a key that leaves the slider at 100% neither.
246
258
  alpha.focus()
247
259
  await userEvent.keyboard('{ArrowRight}')
260
+ await userEvent.keyboard('{End}')
261
+ expect(canvas.getByText('bg-theme-main')).toBeInTheDocument()
248
262
 
249
- // 51% of 255 rounds to 0x82; the picker writes hex in lower case.
250
- await waitFor(() => expect(canvas.getByText('bg-[#1777ea82]')).toBeInTheDocument())
263
+ for (let step = 0; step < 10; step++) await userEvent.keyboard('{ArrowLeft}')
264
+
265
+ // 90% of 255 rounds to 0xe6; the picker writes hex in lower case.
266
+ await waitFor(() => {
267
+ expect(canvas.getByText('bg-[#1777eae6]')).toBeInTheDocument()
268
+ expect(canvas.getByRole('button', { name: 'Hoofdkleur' })).toHaveAttribute(
269
+ 'aria-pressed',
270
+ 'false',
271
+ )
272
+ })
251
273
 
252
274
  await userEvent.click(canvas.getByRole('button', { name: 'Hoofdkleur' }))
253
275
 
254
276
  await waitFor(() => {
255
277
  expect(canvas.getByText('bg-theme-main')).toBeInTheDocument()
256
- expect(canvas.queryByRole('slider', { name: 'Dekking' })).not.toBeInTheDocument()
278
+ expect(canvas.getByRole('slider', { name: 'Dekking' })).toHaveAttribute(
279
+ 'aria-valuetext',
280
+ '100%',
281
+ )
257
282
  })
258
283
  },
259
284
  }
@@ -293,9 +318,13 @@ export const WithAlphaRolePicked: Story = {
293
318
 
294
319
  await userEvent.click(canvas.getByRole('button', { name: 'Hoofdkleur' }))
295
320
 
321
+ // The role wins: it is stored as a role, and the slider is back at 100%.
296
322
  await waitFor(() => {
297
323
  expect(canvas.getByText('bg-theme-main')).toBeInTheDocument()
298
- expect(canvas.queryByRole('slider', { name: 'Dekking' })).not.toBeInTheDocument()
324
+ expect(canvas.getByRole('slider', { name: 'Dekking' })).toHaveAttribute(
325
+ 'aria-valuetext',
326
+ '100%',
327
+ )
299
328
  })
300
329
  },
301
330
  }
@@ -501,6 +530,47 @@ export const GradientRadial: Story = {
501
530
  },
502
531
  }
503
532
 
533
+ /**
534
+ * With `showAlpha` a role stop is not locked: its opacity slider stands at 100%
535
+ * without the hint, and a change turns the stop into the role's hex with alpha.
536
+ * Without `showAlpha` the lock stays, as `GradientRadial` shows.
537
+ */
538
+ export const GradientRoleAlpha: Story = {
539
+ // Axe runs on the open panel, after the play function.
540
+ parameters: { a11y: { test: 'error' } },
541
+ args: { modes: ['solid', 'gradient'], showAlpha: true, themeRoles: THEME_ROLES },
542
+ render: gradientRender('bg-linear-90 from-theme-main to-[#ffffff]'),
543
+ play: async ({ canvasElement }) => {
544
+ const panel = await openAndMeasure(canvasElement)
545
+ const [swatch] = within(panel).getAllByRole('button', { name: 'Kleur kiezen' })
546
+ await userEvent.click(swatch!)
547
+ const dialog = await within(panel).findByRole('dialog', { name: 'Kleur van kleurstop 1' })
548
+ const opacity = within(dialog).getByRole('slider', { name: 'Dekking' })
549
+ expect(opacity).not.toHaveAttribute('aria-disabled')
550
+ expect(opacity).toHaveAttribute('aria-valuetext', '100%')
551
+ expect(opacity).not.toHaveAccessibleDescription()
552
+ expect(dialog).not.toHaveTextContent('Huisstijlkleuren zijn altijd dekkend.')
553
+ expect(within(dialog).getByRole('button', { name: 'Hoofdkleur' })).toHaveAttribute(
554
+ 'aria-pressed',
555
+ 'true',
556
+ )
557
+ // Opening the panel and the popover wrote nothing.
558
+ expect(canvasElement).toHaveTextContent('bg-linear-90 from-theme-main to-[#ffffff]')
559
+
560
+ opacity.focus()
561
+ await userEvent.keyboard('{ArrowLeft}')
562
+
563
+ // 99% of 255 rounds to 0xfc.
564
+ await waitFor(() => {
565
+ expect(canvasElement).toHaveTextContent('bg-linear-90 from-[#1777eafc] to-[#ffffff]')
566
+ expect(within(dialog).getByRole('button', { name: 'Hoofdkleur' })).toHaveAttribute(
567
+ 'aria-pressed',
568
+ 'false',
569
+ )
570
+ })
571
+ },
572
+ }
573
+
504
574
  /**
505
575
  * Three stops, the most the gradient takes: adding is unavailable, with the
506
576
  * reason, and removing is possible. The middle stop is a theme role and the
@@ -397,20 +397,25 @@ describe('EditorColor', () => {
397
397
  })
398
398
 
399
399
  // The way to detach a colour from the theme without changing how it looks.
400
- it('writes a literal when the suggestion with the hex of the selected role is picked', async () => {
401
- const wrapper = mount(EditorColor, {
402
- props: {
403
- modelValue: 'bg-theme-main',
404
- themeRoles: [{ role: 'main', label: 'Hoofdkleur', hex: '#1777ea' }],
405
- suggestions: ['#1777ea'],
406
- },
407
- })
400
+ // The opacity slider is shown for a role as well, so this holds with it too.
401
+ it.each([false, true])(
402
+ 'writes a literal when the suggestion with the hex of the selected role is picked (showAlpha %s)',
403
+ async (showAlpha) => {
404
+ const wrapper = mount(EditorColor, {
405
+ props: {
406
+ modelValue: 'bg-theme-main',
407
+ themeRoles: [{ role: 'main', label: 'Hoofdkleur', hex: '#1777ea' }],
408
+ suggestions: ['#1777ea'],
409
+ showAlpha,
410
+ },
411
+ })
408
412
 
409
- await open(wrapper)
410
- await wrapper.find('button[aria-label="Kies kleur #1777ea"]').trigger('click')
413
+ await open(wrapper)
414
+ await wrapper.find('button[aria-label="Kies kleur #1777ea"]').trigger('click')
411
415
 
412
- expect(wrapper.emitted('update:modelValue')).toEqual([['bg-[#1777ea]']])
413
- })
416
+ expect(wrapper.emitted('update:modelValue')).toEqual([['bg-[#1777ea]']])
417
+ },
418
+ )
414
419
 
415
420
  it('follows the model from a role to a literal and back', async () => {
416
421
  const wrapper = mount(EditorColor, {
@@ -634,20 +639,101 @@ describe('EditorColor', () => {
634
639
  expect(pickerValue(wrapper)).toBe('#1777EAff')
635
640
  })
636
641
 
637
- it('hides the slider while a theme role is selected', async () => {
642
+ it('keeps the slider at 100% while a theme role is selected, and opening writes nothing', async () => {
638
643
  const wrapper = mount(EditorColor, {
639
644
  props: { modelValue: 'bg-theme-main', themeRoles: THEME_ROLES, showAlpha: true },
640
645
  })
641
646
 
642
- expect(wrapper.findComponent(NexxtColorPicker).props('showAlpha')).toBe(false)
647
+ expect(wrapper.findComponent(NexxtColorPicker).props('showAlpha')).toBe(true)
648
+
649
+ await open(wrapper)
650
+
651
+ expect(alphaThumb(wrapper).attributes('aria-valuetext')).toBe('100%')
652
+ expect(pressedLabels(wrapper)).toEqual(['Hoofdkleur'])
653
+ expect(trigger(wrapper).text()).toBe('Hoofdkleur')
654
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
655
+ })
656
+
657
+ // The slider stays mounted while the model turns into a role from outside,
658
+ // so the picker's colour changes under it: that must not come back as a write.
659
+ it('writes nothing when a role becomes active under an open slider', async () => {
660
+ const wrapper = mount(EditorColor, {
661
+ props: { modelValue: 'bg-[#00FF0080]', themeRoles: THEME_ROLES, showAlpha: true },
662
+ })
663
+
664
+ await open(wrapper)
665
+ expect(alphaThumb(wrapper).attributes('aria-valuetext')).toBe('50%')
666
+
667
+ await wrapper.setProps({ modelValue: 'bg-theme-main' })
668
+ await flushPromises()
669
+
670
+ expect(alphaThumb(wrapper).attributes('aria-valuetext')).toBe('100%')
671
+ expect(hexField(wrapper).element.value).toBe('#1777ea')
672
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
673
+ })
674
+
675
+ it.each(['ArrowRight', 'End', 'PageUp'])(
676
+ 'keeps the role on %s, a key that leaves the slider at full opacity',
677
+ async (key) => {
678
+ const wrapper = mount(EditorColor, {
679
+ props: { modelValue: 'bg-theme-main', themeRoles: THEME_ROLES, showAlpha: true },
680
+ })
681
+
682
+ await open(wrapper)
683
+ await alphaThumb(wrapper).trigger('keydown', { key })
684
+
685
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
686
+ },
687
+ )
688
+
689
+ it('detaches the role into its hex with alpha when the slider moves, and keeps a literal at 100%', async () => {
690
+ const role: EditorColorValue = 'bg-theme-main'
691
+ const wrapper = mount(EditorColor, {
692
+ props: {
693
+ modelValue: role,
694
+ themeRoles: THEME_ROLES,
695
+ showAlpha: true,
696
+ 'onUpdate:modelValue': (value: EditorColorValue) =>
697
+ wrapper.setProps({ modelValue: value }),
698
+ },
699
+ })
700
+
701
+ await open(wrapper)
702
+ await alphaThumb(wrapper).trigger('keydown', { key: 'ArrowLeft' })
703
+ await flushPromises()
704
+
705
+ // 99% of 255 rounds to 0xfc; the role's hex comes in lower case.
706
+ expect(wrapper.emitted('update:modelValue')).toEqual([['bg-[#1777eafc]']])
707
+ expect(pressedLabels(wrapper)).toEqual([])
708
+ expect(trigger(wrapper).text()).toBe('#1777eafc')
709
+ expect(alphaThumb(wrapper).attributes('aria-valuetext')).toBe('99%')
710
+
711
+ // Back at full opacity it is the role's colour, but no longer the role.
712
+ await alphaThumb(wrapper).trigger('keydown', { key: 'ArrowRight' })
713
+ await flushPromises()
714
+
715
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['bg-[#1777ea]'])
716
+ expect(pressedLabels(wrapper)).toEqual([])
717
+ expect(trigger(wrapper).text()).toBe('#1777ea')
718
+ })
719
+
720
+ // A role stop has no opacity without the slider; that lock is covered in
721
+ // EditorColorGradient.test.ts. Here the solid mode keeps working unchanged.
722
+ it('leaves the slider out for a role without showAlpha', async () => {
723
+ const wrapper = mount(EditorColor, {
724
+ props: { modelValue: 'bg-theme-main', themeRoles: THEME_ROLES },
725
+ })
643
726
 
644
727
  await open(wrapper)
645
728
 
646
729
  expect(alphaThumb(wrapper).exists()).toBe(false)
730
+ expect(pickerValue(wrapper)).toBe('#1777EAff')
731
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
647
732
  })
648
733
 
649
- // Picking the role flips the picker's value and its alpha channel at once;
650
- // the picker must not write the old colour back, stripped, over the role.
734
+ // A role has no opacity, so it wins over the slider: picked below 100% it is
735
+ // stored as the role, and the slider jumps back to 100% without writing the
736
+ // old colour back over it.
651
737
  it('writes only the role when one is picked over a translucent literal', async () => {
652
738
  // Typed up front: the handler refers to `wrapper`, which stops inference
653
739
  // from narrowing an inline literal to the model's type.
@@ -669,12 +755,13 @@ describe('EditorColor', () => {
669
755
  await flushPromises()
670
756
 
671
757
  expect(wrapper.emitted('update:modelValue')).toEqual([['bg-theme-main']])
672
- expect(alphaThumb(wrapper).exists()).toBe(false)
758
+ expect(alphaThumb(wrapper).attributes('aria-valuetext')).toBe('100%')
759
+ expect(pressedLabels(wrapper)).toEqual(['Hoofdkleur'])
673
760
  expect(trigger(wrapper).text()).toBe('Hoofdkleur')
674
761
  expect(hexField(wrapper).element.value).toBe('#1777ea')
675
762
  })
676
763
 
677
- it('offers the slider again once a literal replaces the role', async () => {
764
+ it('shows a literal that replaces the role with its opacity', async () => {
678
765
  const wrapper = mount(EditorColor, {
679
766
  props: { modelValue: 'bg-theme-main', themeRoles: THEME_ROLES, showAlpha: true },
680
767
  })
@@ -92,11 +92,13 @@ export interface NexxtEditorColorProps {
92
92
  /**
93
93
  * Offer an opacity slider in the picker. A colour picked below full opacity is
94
94
  * written as an 8-digit hex (`bg-[#1777ea80]`); a fully opaque one keeps the
95
- * 6-digit form. The slider is hidden while a theme role is selected: a role
96
- * class carries no opacity. Off by default, in which case a stored 8-digit hex
97
- * is shown opaque and only replaced once a new colour is picked. Turning it off
98
- * while a translucent colour is set does write at once: the picker drops the
99
- * alpha and the model gets the opaque 6-digit hex.
95
+ * 6-digit form. While a theme role is selected the slider stands at 100%, as a
96
+ * role class carries no opacity; moving it writes the role's hex with that
97
+ * opacity, so the role is no longer selected. The same goes for a role stop of
98
+ * a gradient, which without `showAlpha` stays opaque. Off by default, in which
99
+ * case a stored 8-digit hex is shown opaque and only replaced once a new colour
100
+ * is picked. Turning it off while a translucent colour is set does write at
101
+ * once: the picker drops the alpha and the model gets the opaque 6-digit hex.
100
102
  */
101
103
  showAlpha?: boolean
102
104
  /**
@@ -584,6 +584,45 @@ describe('NexxtEditorColor gradient', () => {
584
584
  expect(w.find('[role="dialog"] [aria-disabled="true"][role="slider"]').exists()).toBe(false)
585
585
  })
586
586
 
587
+ it('unlocks the opacity of a role stop with showAlpha, and a change makes it a hex stop', async () => {
588
+ const { w, values } = await setup('bg-linear-90 from-theme-main to-[#ffffff]', {
589
+ showAlpha: true,
590
+ })
591
+ await openPopover(w, 0)
592
+ const dialog = w.find('[role="dialog"]')
593
+ const thumb = dialog.find('[role="slider"][aria-labelledby]')
594
+ expect(thumb.attributes('aria-disabled')).toBeUndefined()
595
+ expect(thumb.attributes('aria-describedby')).toBeUndefined()
596
+ expect(thumb.attributes('aria-valuenow')).toBe('100')
597
+ expect(dialog.text()).not.toContain('Huisstijlkleuren')
598
+ expect(dialog.find('button[aria-label="Hoofdkleur"]').attributes('aria-pressed')).toBe('true')
599
+ // Opening the panel and the popover wrote nothing.
600
+ expect(values).toEqual([])
601
+
602
+ await thumb.trigger('keydown', { key: 'ArrowRight' })
603
+ await settle()
604
+ expect(values).toEqual([])
605
+
606
+ await thumb.trigger('keydown', { key: 'ArrowLeft' })
607
+ await settle()
608
+ // 99% of 255 rounds to 0xfc.
609
+ expect(values).toEqual(['bg-linear-90 from-[#1777eafc] to-[#ffffff]'])
610
+ expect(
611
+ w.find('[role="dialog"] button[aria-label="Hoofdkleur"]').attributes('aria-pressed'),
612
+ ).toBe('false')
613
+ })
614
+
615
+ it('keeps the opacity of a role stop locked without showAlpha', async () => {
616
+ const { w, values } = await setup('bg-linear-90 from-theme-main to-[#ffffff]')
617
+ await openPopover(w, 0)
618
+ const thumb = w.find('[role="dialog"] [role="slider"][aria-disabled="true"]')
619
+ expect(thumb.exists()).toBe(true)
620
+
621
+ await thumb.trigger('keydown', { key: 'ArrowLeft' })
622
+ await settle()
623
+ expect(values).toEqual([])
624
+ })
625
+
587
626
  it('offers the neighbouring stop first among the suggestions', async () => {
588
627
  const { w } = await setup('bg-linear-90 from-[#111111] to-[#222222]', {
589
628
  suggestions: ['#ff0000'],
@@ -31,7 +31,10 @@ export interface ColorFieldProps<P extends string> {
31
31
  suggestions?: string[]
32
32
  /** Theme roles offered above the suggestions; an entry with an unreadable hex is left out. */
33
33
  themeRoles?: EditorColorThemeRole[]
34
- /** Offer an opacity slider, hidden while a theme role is selected. */
34
+ /**
35
+ * Offer an opacity slider, also while a theme role is selected: it then stands
36
+ * at 100%, and moving it turns the role into its hex with that opacity.
37
+ */
35
38
  showAlpha?: boolean
36
39
  /**
37
40
  * The fills offered: `['solid']` (the default), `['solid', 'gradient']` or
@@ -122,25 +125,27 @@ const offeredRoles = computed(() => themeRoles.filter((entry) => ROLE_HEX_PATTER
122
125
  const activeRole = computed(() => offeredRoles.value.find(({ role }) => role === storedRole.value))
123
126
 
124
127
  /**
128
+ * A role's hex as the picker gets it: opaque, in the 8-digit form.
129
+ *
125
130
  * Picking the role's own colour -- from the suggestions, or typed -- is how a
126
131
  * colour is detached from the theme without changing how it looks. The picker
127
- * reports nothing when it is told to pick the string it already has, so a role's
128
- * hex is handed over in its 8-digit form: with `show-alpha` off the picker never
129
- * reports an alpha pair, which makes every pick a change.
132
+ * reports nothing when it is told to pick the string it already has, so the hex
133
+ * is handed over with an `ff` pair: the picker writes an opaque colour as six
134
+ * digits and an alpha pair only below full opacity, so it never writes this
135
+ * string back, and every pick is a change. That holds with the opacity slider
136
+ * shown as well.
137
+ *
138
+ * A role has no opacity, so an alpha pair in the role's own hex is replaced by
139
+ * `ff`: the slider of an active role stands at 100%. Moving it below that writes
140
+ * the role's hex with the alpha pair, which detaches the role the same way;
141
+ * back at 100% it stays a literal (`OPAQUE_ALPHA_PATTERN`).
130
142
  *
131
- * A role has no opacity here, so an alpha pair in the role's own hex is replaced
132
- * by `ff`. Handed over translucent, the picker would drop that alpha the moment
133
- * the role turns `show-alpha` off, and write the opaque hex over the role.
143
+ * Opening the panel, or a role becoming active, writes nothing: the picker's
144
+ * primitives only report a change the user makes, and a key that leaves the
145
+ * slider where it is (ArrowRight at 100%) is no change.
134
146
  */
135
147
  const pickerHex = (roleHex: string) => `${roleHex.slice(0, 7)}ff`
136
148
 
137
- /**
138
- * A role class has no opacity, so the slider is only offered for a literal.
139
- * Hiding it is also what keeps the trick in `pickerHex` working: without the
140
- * alpha channel the picker reads the role's hex as opaque.
141
- */
142
- const alphaEnabled = computed(() => showAlpha && !activeRole.value)
143
-
144
149
  // An unparseable value -- empty, a token class such as `bg-red-500`, or copy
145
150
  // from before this component -- leaves the picker empty and its placeholder
146
151
  // visible, rather than being reported as a problem: the first pick writes a
@@ -401,7 +406,7 @@ watch(
401
406
  :label="label"
402
407
  :describedby="describedby"
403
408
  :formats="['hex']"
404
- :show-alpha="alphaEnabled"
409
+ :show-alpha="showAlpha"
405
410
  :placeholder="placeholder"
406
411
  :suggestions="{ hex: suggestions }"
407
412
  :suggestions-label="suggestionsLabel"
@@ -616,6 +616,64 @@ describe('NexxtColorPicker', () => {
616
616
  wrapper.unmount()
617
617
  })
618
618
 
619
+ /** A two-stop gradient whose first stop is bound to a preset, with that stop's popover open. */
620
+ const openPresetStop = async (showAlpha: boolean) => {
621
+ const wrapper = mount(NexxtColorPicker, {
622
+ props: {
623
+ modes: ['gradient'],
624
+ showAlpha,
625
+ gradientValue: {
626
+ type: 'linear',
627
+ angle: 90,
628
+ centerX: 50,
629
+ centerY: 50,
630
+ stops: [
631
+ { color: '#111111', position: 0, preset: { key: 'main', label: 'Hoofdkleur' } },
632
+ { color: '#ffffff', position: 100 },
633
+ ],
634
+ },
635
+ },
636
+ attachTo: document.body,
637
+ })
638
+ await open(wrapper)
639
+ await wrapper.find('button[aria-label="Kleur kiezen"]').trigger('click')
640
+ await flushPromises()
641
+ await nextTick()
642
+ const thumb = wrapper.find('[role="dialog"] [role="slider"][aria-labelledby]')
643
+ return { wrapper, thumb }
644
+ }
645
+
646
+ it('locks the opacity of a preset stop without showAlpha', async () => {
647
+ const { wrapper, thumb } = await openPresetStop(false)
648
+ expect(thumb.attributes('aria-disabled')).toBe('true')
649
+ expect(wrapper.text()).toContain('Huisstijlkleuren zijn altijd dekkend.')
650
+
651
+ await thumb.trigger('keydown', { key: 'ArrowLeft' })
652
+ await flushPromises()
653
+ expect(wrapper.emitted('update:gradientValue')).toBeUndefined()
654
+ wrapper.unmount()
655
+ })
656
+
657
+ it('unbinds a preset stop when its opacity changes with showAlpha', async () => {
658
+ const { wrapper, thumb } = await openPresetStop(true)
659
+ expect(thumb.attributes('aria-disabled')).toBeUndefined()
660
+ expect(thumb.attributes('aria-valuenow')).toBe('100')
661
+ expect(wrapper.text()).not.toContain('Huisstijlkleuren zijn altijd dekkend.')
662
+ expect(wrapper.emitted('update:gradientValue')).toBeUndefined()
663
+
664
+ await thumb.trigger('keydown', { key: 'ArrowLeft' })
665
+ await flushPromises()
666
+ // 99% of 255 rounds to 0xfc; the preset is gone from the stop.
667
+ expect(wrapper.emitted('update:gradientValue')?.at(-1)?.[0]).toMatchObject({
668
+ stops: [{ color: '#111111fc', position: 0 }, { color: '#ffffff' }],
669
+ })
670
+ expect(wrapper.emitted('update:gradientValue')?.at(-1)?.[0]).not.toHaveProperty(
671
+ 'stops.0.preset',
672
+ )
673
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
674
+ wrapper.unmount()
675
+ })
676
+
619
677
  it('emits close when the panel closes', async () => {
620
678
  const wrapper = mount(NexxtColorPicker, {
621
679
  props: { modes: ['solid', 'gradient'] },
@@ -61,7 +61,9 @@ export interface NexxtColorPickerProps {
61
61
  /**
62
62
  * Offer an opacity slider and accept colours below full opacity. Turning it
63
63
  * off while the colour is translucent writes the opaque colour to the model
64
- * at once.
64
+ * at once. The gradient mode always has the slider; without `showAlpha` it is
65
+ * locked at 100% for a stop bound to a preset, with it a change unbinds the
66
+ * stop from its preset.
65
67
  */
66
68
  showAlpha?: boolean
67
69
  formats?: ColorFormat[]
@@ -283,8 +285,15 @@ const modeModel = computed({
283
285
  /** The opacity slider is always there in the gradient mode. */
284
286
  const alphaOn = computed(() => props.showAlpha || gradientMode.value)
285
287
 
286
- /** A stop bound to a preset is always opaque: its slider stays, at 100 and disabled. */
287
- const alphaLocked = computed(() => gradientMode.value && !!editor.selected.value?.preset)
288
+ /**
289
+ * Without `showAlpha` a stop bound to a preset is always opaque: its slider
290
+ * stays, at 100 and disabled. With `showAlpha` the slider stays usable, as it is
291
+ * for the solid colour, and a change unbinds the stop from its preset
292
+ * (`commitColor`), so the stop becomes a hex with that opacity.
293
+ */
294
+ const alphaLocked = computed(
295
+ () => gradientMode.value && !props.showAlpha && !!editor.selected.value?.preset,
296
+ )
288
297
 
289
298
  const panelClass = computed(
290
299
  () =>