@nexdom/uimed-vue 2.0.0-beta.45 → 2.0.0-beta.47

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/AGENTS.md CHANGED
@@ -153,7 +153,7 @@ Use an existing component (e.g. `button`) as the reference, and deliver all of t
153
153
  2. The `U`-prefixed export in `src/components/index.ts`.
154
154
  3. A usage guide at `docs/guide/components/<name>.md`, with `<demo>` examples (and a `<playground>` section when the component has configurable props), and an API reference at `docs/api/components/<name>.md` (props, events and slots tables). Both in Portuguese. In a demo's template inside a `.md` file, don't put an attribute with a template literal after one with an arrow function in the same tag: the Markdown type-check (`vue-tsc`) reads the `>` of `=>` as the end of the tag and fails with `TS1003`. Order the attributes so the template literal comes first.
155
155
  4. Both pages registered in the sidebar at `docs/.vitepress/config.ts`.
156
- 5. `e2e/components/<name>.spec.ts` covering the guide's interactive examples and a "UI consistency" screenshot check (plus accessible snapshots where relevant). Commit the generated files under `__snapshots__`/`__screenshot__`.
156
+ 5. `e2e/components/<name>.spec.ts` covering the guide's interactive examples, an "accessibility" check (see [Testing conventions](#testing-conventions)) and a "UI consistency" screenshot check (plus accessible snapshots where relevant). Commit the generated files under `__snapshots__`/`__screenshot__`.
157
157
  6. The full CI sequence passing locally.
158
158
 
159
159
  ## Adding a new composable
@@ -163,7 +163,7 @@ Use an existing composable (e.g. `use-toast`) as the reference, and deliver in t
163
163
  1. `src/composables/<group>/<name>.ts` and `__tests__/<name>.test.ts`, plus the types in the group's `types.ts`. If it relies on an internal component (as `useToast` relies on the internal `Toast` rendered by `UMain`), that component follows the component layout but isn't exported.
164
164
  2. The export in `src/composables/index.ts`, and its assertion in `src/composables/__tests__/index.test.ts`.
165
165
  3. A usage guide at `docs/guide/composables/<name>.md` and an API reference at `docs/api/composables/<name>.md`, both in Portuguese, registered in both sidebars at `docs/.vitepress/config.ts` and listed in `docs/api/index.md` and in the table of `docs/guide/index.md`. If it relies on `UMain` (like `useToast`), also mention it in `UMain`'s guide and API pages.
166
- 4. `e2e/composables/<name>.spec.ts` covering the guide's examples and a "UI consistency" screenshot check.
166
+ 4. `e2e/composables/<name>.spec.ts` covering the guide's examples, an "accessibility" check (see [Testing conventions](#testing-conventions)) and a "UI consistency" screenshot check.
167
167
  5. The full CI sequence passing locally.
168
168
 
169
169
  ## Code conventions
@@ -178,7 +178,7 @@ Use an existing composable (e.g. `use-toast`) as the reference, and deliver in t
178
178
  - Reuse the shared internal composables for props that recur across components, so every component maps them to Vuetify the same way, instead of mapping them again:
179
179
  - `fullWidth`/`fullHeight`: `useFullWidth`/`useFullHeight` (`src/composables/dimensions/dimensions.ts`), applying Vuetify's `w-100`/`h-100` classes even where the component has `width`/`height` props.
180
180
  - `alignX`/`alignY`: `useAlignX`/`useAlignY` (`src/composables/alignment/alignment.ts`), with the `AlignX`/`AlignY` types from the group's `types.ts`.
181
- - `color`: `useVuetifyColor` (`src/composables/colors/use-vuetify-color.ts`), with the `ColorVariant` type from the group's `types.ts`.
181
+ - `color`: `useVuetifyColor` (`src/composables/colors/use-vuetify-color.ts`), with the `ColorVariant` type from the group's `types.ts`. Where the color is the text's instead of a fill (e.g. `UButton`'s `ghost` variant), use `useVuetifyTextColor` (`use-vuetify-text-color.ts`) instead: the fills are too light to reach WCAG AA's contrast as text, so each one has a darker text shade in the theme.
182
182
 
183
183
  A new prop that does the same as one of these takes its name and type. When a second component needs a mapping that only one component has, move it into a shared composable first.
184
184
 
@@ -220,6 +220,7 @@ Use an existing composable (e.g. `use-toast`) as the reference, and deliver in t
220
220
  - With fake timers in jsdom, Vuetify transitions (e.g. of `VDialog`, `VMenu`) don't finish on their own. Emit the transition events on the Vuetify component (`wrapper.findComponent(VDialog).vm.$emit("afterLeave")`) or advance the timers with `await vi.runAllTimersAsync()`.
221
221
  - Coverage threshold is 100%; mutation testing threshold is 100% (break at 100). Don't add code paths without covering tests. Mutants that don't compile are reported as compile errors and don't count toward the score.
222
222
  - E2E tests (Playwright, `e2e/`) run against the built docs preview (`http://localhost:4173/uimed-vue/`). Snapshots/screenshots live under `__snapshots__`/`__screenshot__` next to each spec.
223
+ - Every E2E spec has an "accessibility" `describe` that calls `expectNoA11yViolations(page)` from `e2e/utils.ts` at least once. It runs axe against WCAG 2.2 AA and its best practices on the page's demos and overlays, in both of the docs' color schemes (the demos keep the library's light theme on a background of their own in dark mode), and fails on any violation. Check each state that renders something new, too: open overlays (menus, dialogs, toasts) and every color of a variant whose text takes the color (as `button.spec.ts` does for `ghost`). Contrast is the most common failure: the theme's colors and text shades must keep 4.5:1, which `src/__tests__/index.test.ts` checks. Don't make a check pass by disabling rules or excluding elements: fix the cause, or stop and ask. The helper only disables the landmark rules that fail because of the docs' layout, with a comment saying why.
223
224
  - Before keeping a new screenshot as a baseline, open it and check it against how plain Vuetify renders the same component: overflowing content shows its scrollbar, buttons and cards keep their native paddings, nothing touches the edges or gets clipped. A screenshot that only "looks fine" isn't enough: name what you checked in your report.
224
225
  - Generate and update screenshots only on Linux, as CI does: in the Dev Container, or in the Playwright Docker image of the version in `package.json` (`mcr.microsoft.com/playwright:v<version>-noble`). Screenshots taken on Windows or macOS render fonts differently and fail in CI.
225
226
  - Update baselines with `--update-snapshots=changed`, scoped to the spec you changed. `--update-snapshots=all` also rewrites accessible snapshots (`.aria.yml`) that didn't need to change.
@@ -575,8 +575,8 @@ declare const __VLS_export$16: import("vue", { with: { "resolution-mode": "impor
575
575
  "onUpdate:modelValue"?: ((value: string) => any) | undefined;
576
576
  }>, {
577
577
  variant: TextFieldVariant;
578
- clearable: boolean;
579
578
  type: TextFieldType;
579
+ clearable: boolean;
580
580
  }, {}, {}, {}, string, import("vue", { with: { "resolution-mode": "import" } }).ComponentProvideOptions, false, {}, any>;
581
581
  //#endregion
582
582
  //#region src/components/inputs/checkbox/types.d.ts
@@ -1,4 +1,4 @@
1
- import { a as messages, n as owners$1, r as requests, s as useVuetifyColor } from "./use-dialog-Ck2_ZW7T.js";
1
+ import { a as messages, c as colorToVuetifyTextColor, n as owners$1, r as requests, s as useVuetifyColor } from "./use-dialog-Czq2P-Z9.js";
2
2
  import { useLocale, useMask } from "vuetify";
3
3
  import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createSlots, createTextVNode, createVNode, defineComponent, guardReactiveProps, inject, isRef, mergeModels, mergeProps, nextTick, normalizeClass, normalizeProps, onMounted, onScopeDispose, onUnmounted, openBlock, provide, readonly, ref, renderList, renderSlot, resolveDynamicComponent, shallowRef, toDisplayString, toValue, unref, useId, useModel, useSlots, useTemplateRef, watch, withCtx, withKeys, withModifiers } from "vue";
4
4
  import { pt } from "vuetify/locale";
@@ -14,6 +14,13 @@ function routeToProps(route) {
14
14
  const location = toValue(route);
15
15
  return isExternalRoute(location) ? { href: location } : { to: location };
16
16
  }
17
+ /**
18
+ * Maps a menu item's route to its list item's props. An item without a route only runs its action,
19
+ * so it's a button: Vuetify would make it a list item, which the menus' groups of links don't allow.
20
+ */
21
+ function routeToListItemProps(route) {
22
+ return route ? routeToProps(route) : { role: "button" };
23
+ }
17
24
  function isExternalRoute(route) {
18
25
  return typeof route === "string" && route.startsWith("http");
19
26
  }
@@ -186,7 +193,8 @@ var skeleton_loader_default = /* @__PURE__ */ defineComponent({
186
193
  });
187
194
  //#endregion
188
195
  //#region src/components/app-bar/app-bar.vue?vue&type=script&setup=true&lang.ts
189
- const _hoisted_1$3 = { class: "opacity-60" };
196
+ const _hoisted_1$3 = { class: "text-high-emphasis" };
197
+ const _hoisted_2$1 = { class: "text-medium-emphasis" };
190
198
  //#endregion
191
199
  //#region src/components/app-bar/app-bar.vue
192
200
  var app_bar_default = /* @__PURE__ */ defineComponent({
@@ -313,11 +321,14 @@ var app_bar_default = /* @__PURE__ */ defineComponent({
313
321
  active: !notification.read,
314
322
  title: notification.title,
315
323
  subtitle: notification.subtitle,
316
- "active-class": "text-primary"
317
- }, createSlots({ _: 2 }, [notification.when ? {
324
+ "active-class": "text-primary-text"
325
+ }, createSlots({
326
+ subtitle: withCtx(({ subtitle }) => [createElementVNode("span", _hoisted_1$3, toDisplayString(subtitle), 1)]),
327
+ _: 2
328
+ }, [notification.when ? {
318
329
  name: "append",
319
330
  fn: withCtx(() => [createVNode(unref(VListItemAction), { end: "" }, {
320
- default: withCtx(() => [createElementVNode("small", _hoisted_1$3, toDisplayString(notification.when), 1)]),
331
+ default: withCtx(() => [createCommentVNode(" No prop sets the secondary text color, which follows the theme's contrast "), createElementVNode("small", _hoisted_2$1, toDisplayString(notification.when), 1)]),
321
332
  _: 2
322
333
  }, 1024)]),
323
334
  key: "0"
@@ -354,15 +365,24 @@ var app_bar_default = /* @__PURE__ */ defineComponent({
354
365
  "data-testid": userMenuDataTestid.value
355
366
  }, {
356
367
  prepend: withCtx(() => [createVNode(unref(VAvatar), mergeProps(userAvatarProps.value, { color: "primary" }), null, 16)]),
357
- default: withCtx(() => [createVNode(unref(VDivider)), createVNode(unref(VList), { nav: "" }, {
368
+ default: withCtx(() => [createVNode(unref(VDivider)), createVNode(unref(VList), {
369
+ nav: "",
370
+ role: "group"
371
+ }, {
358
372
  default: withCtx(() => [(openBlock(true), createElementBlock(Fragment, null, renderList(props.user?.options, (option, index) => {
359
373
  return openBlock(), createElementBlock(Fragment, { key: index }, [index > 0 ? (openBlock(), createBlock(unref(VDivider), {
360
374
  key: 0,
361
375
  class: "mt-1"
362
376
  })) : createCommentVNode("v-if", true), isParentOption(option) ? (openBlock(), createBlock(unref(VListGroup), { key: 1 }, {
363
- activator: withCtx(({ props }) => [createVNode(unref(VListItem), mergeProps({ ref_for: true }, props, { title: option.description }), null, 16, ["title"])]),
377
+ activator: withCtx(({ props, isOpen }) => [createVNode(unref(VListItem), mergeProps({ ref_for: true }, props, {
378
+ title: option.description,
379
+ "aria-expanded": isOpen,
380
+ role: void 0,
381
+ tag: "button",
382
+ type: "button"
383
+ }), null, 16, ["title", "aria-expanded"])]),
364
384
  default: withCtx(() => [(openBlock(true), createElementBlock(Fragment, null, renderList(option.items, (childOption, index) => {
365
- return openBlock(), createBlock(unref(VListItem), mergeProps({ key: index }, { ref_for: true }, unref(routeToProps)(childOption.route), {
385
+ return openBlock(), createBlock(unref(VListItem), mergeProps({ key: index }, { ref_for: true }, unref(routeToListItemProps)(childOption.route), {
366
386
  title: childOption.description,
367
387
  onClick: childOption.action
368
388
  }), null, 16, ["title", "onClick"]);
@@ -371,7 +391,7 @@ var app_bar_default = /* @__PURE__ */ defineComponent({
371
391
  }, 1024)) : (openBlock(), createBlock(unref(VListItem), mergeProps({
372
392
  key: 2,
373
393
  title: option.description
374
- }, { ref_for: true }, unref(routeToProps)(option.route), { onClick: option.action }), null, 16, ["title", "onClick"]))], 64);
394
+ }, { ref_for: true }, unref(routeToListItemProps)(option.route), { onClick: option.action }), null, 16, ["title", "onClick"]))], 64);
375
395
  }), 128))]),
376
396
  _: 1
377
397
  })]),
@@ -805,15 +825,26 @@ var navigation_menu_default = /* @__PURE__ */ defineComponent({
805
825
  type: "list-item@6",
806
826
  color: "primary"
807
827
  }, {
808
- default: withCtx(() => [createVNode(unref(VList), { nav: "" }, {
828
+ default: withCtx(() => [createCommentVNode(" Vuetify's `list` role only allows list items, but the items are links "), createVNode(unref(VList), {
829
+ nav: "",
830
+ role: "group"
831
+ }, {
809
832
  default: withCtx(() => [(openBlock(true), createElementBlock(Fragment, null, renderList(filteredItems.value, (item, index) => {
810
833
  return openBlock(), createElementBlock(Fragment, { key: index }, [isParentItem(item) ? (openBlock(), createBlock(unref(VListGroup), { key: 0 }, {
811
- activator: withCtx(({ props: activatorProps }) => [createVNode(unref(VListItem), mergeProps({ ref_for: true }, activatorProps, {
834
+ activator: withCtx(({ props: activatorProps, isOpen }) => [createVNode(unref(VListItem), mergeProps({ ref_for: true }, activatorProps, {
812
835
  title: item.description,
813
- "prepend-icon": item.icon && unref(icons)[item.icon]
814
- }), null, 16, ["title", "prepend-icon"])]),
836
+ "prepend-icon": item.icon && unref(icons)[item.icon],
837
+ "aria-expanded": isOpen,
838
+ role: void 0,
839
+ tag: "button",
840
+ type: "button"
841
+ }), null, 16, [
842
+ "title",
843
+ "prepend-icon",
844
+ "aria-expanded"
845
+ ])]),
815
846
  default: withCtx(() => [(openBlock(true), createElementBlock(Fragment, null, renderList(item.items, (childItem, childIndex) => {
816
- return openBlock(), createBlock(unref(VListItem), mergeProps({ key: childIndex }, { ref_for: true }, unref(routeToProps)(childItem.route), {
847
+ return openBlock(), createBlock(unref(VListItem), mergeProps({ key: childIndex }, { ref_for: true }, unref(routeToListItemProps)(childItem.route), {
817
848
  title: childItem.description,
818
849
  "prepend-icon": childItem.icon && unref(icons)[childItem.icon],
819
850
  onClick: childItem.action
@@ -828,7 +859,7 @@ var navigation_menu_default = /* @__PURE__ */ defineComponent({
828
859
  key: 1,
829
860
  title: item.description,
830
861
  "prepend-icon": item.icon && unref(icons)[item.icon]
831
- }, { ref_for: true }, unref(routeToProps)(item.route), { onClick: item.action }), null, 16, [
862
+ }, { ref_for: true }, unref(routeToListItemProps)(item.route), { onClick: item.action }), null, 16, [
832
863
  "title",
833
864
  "prepend-icon",
834
865
  "onClick"
@@ -962,10 +993,19 @@ var toast_default = /* @__PURE__ */ defineComponent({
962
993
  }
963
994
  });
964
995
  //#endregion
996
+ //#region src/composables/colors/use-vuetify-text-color.ts
997
+ /**
998
+ * Maps a color to the theme color to use when it's the text color, e.g. in a ghost button, so it
999
+ * keeps WCAG AA's contrast on the surface and background.
1000
+ */
1001
+ function useVuetifyTextColor(color) {
1002
+ return computed(() => colorToVuetifyTextColor[toValue(color) ?? "primary"]);
1003
+ }
1004
+ //#endregion
965
1005
  //#region src/composables/button/button.ts
966
1006
  const buttonVariantToVuetifyVariant = {
967
1007
  primary: "elevated",
968
- secondary: "flat",
1008
+ secondary: "outlined",
969
1009
  ghost: "text"
970
1010
  };
971
1011
  function useButtonVariant(variant) {
@@ -994,6 +1034,17 @@ function useButtonType(type, route, blocked) {
994
1034
  });
995
1035
  }
996
1036
  /**
1037
+ * Maps the button's color to Vuetify's. Secondary (outlined) and ghost buttons have no fill, so
1038
+ * their color is the text's, which takes the color's text shade to keep WCAG AA's contrast.
1039
+ */
1040
+ function useButtonColor(color, variant) {
1041
+ const fillColor = useVuetifyColor(color);
1042
+ const textColor = useVuetifyTextColor(color);
1043
+ return computed(() => {
1044
+ return toValue(variant) === "secondary" || toValue(variant) === "ghost" ? textColor.value : fillColor.value;
1045
+ });
1046
+ }
1047
+ /**
997
1048
  * The button's `form`, or `undefined` when it has a `route`, even while blocked, since `form`
998
1049
  * doesn't apply to it.
999
1050
  */
@@ -1048,7 +1099,7 @@ var button_default = /* @__PURE__ */ defineComponent({
1048
1099
  const props = __props;
1049
1100
  const emit = __emit;
1050
1101
  const variant = useButtonVariant(() => props.variant);
1051
- const color = useVuetifyColor(() => props.color);
1102
+ const color = useButtonColor(() => props.color, () => props.variant);
1052
1103
  const widthClass = useFullWidth(() => props.fullWidth);
1053
1104
  const isDisabled = computed(() => props.disabled || props.loading);
1054
1105
  const routeProps = useRouteProps(useButtonRoute(() => props.route, isDisabled));
@@ -1,4 +1,4 @@
1
- import { i as useDialog, o as useToast, t as openDialog } from "./use-dialog-Ck2_ZW7T.js";
1
+ import { i as useDialog, o as useToast, t as openDialog } from "./use-dialog-Czq2P-Z9.js";
2
2
  import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, toValue, watch } from "vue";
3
3
  import { isDeepEqual } from "@nexdom/shared/utils";
4
4
  import { onBeforeRouteLeave } from "vue-router";
package/dist/index.js CHANGED
@@ -24,9 +24,18 @@ function createUimed() {
24
24
  success: "#A9EFC5",
25
25
  warning: "#FFE596",
26
26
  error: "#D92D20",
27
- info: "#9ACBE5"
27
+ info: "#9ACBE5",
28
+ "on-primary": "#000000",
29
+ "primary-text": "#007A4A",
30
+ "success-text": "#16783E",
31
+ "info-text": "#277096",
32
+ "warning-text": "#846300",
33
+ "error-text": "#C92A1E"
28
34
  },
29
- variables: { "border-color": "#D0D5DD" }
35
+ variables: {
36
+ "border-color": "#D0D5DD",
37
+ "medium-emphasis-opacity": .7
38
+ }
30
39
  } }
31
40
  }
32
41
  });
@@ -7,6 +7,19 @@ const colorToVuetifyColor = {
7
7
  caution: "warning",
8
8
  danger: "error"
9
9
  };
10
+ /**
11
+ * The theme colors to use when a color is the text color, on the surface or background, instead
12
+ * of a fill. The fills are too light to reach WCAG AA's contrast as text, so each one has a darker
13
+ * shade, except `secondary`, which is almost white and gives way to the theme's text color.
14
+ */
15
+ const colorToVuetifyTextColor = {
16
+ primary: "primary-text",
17
+ secondary: "on-surface",
18
+ positive: "success-text",
19
+ informative: "info-text",
20
+ caution: "warning-text",
21
+ danger: "error-text"
22
+ };
10
23
  //#endregion
11
24
  //#region src/composables/colors/use-vuetify-color.ts
12
25
  function useVuetifyColor(color) {
@@ -45,7 +58,8 @@ function toast(options) {
45
58
  const toastMessage = {
46
59
  text,
47
60
  color: color.value,
48
- prependIcon: `$${color.value}`
61
+ prependIcon: `$${color.value}`,
62
+ timerColor: `rgb(var(--v-theme-on-${color.value}))`
49
63
  };
50
64
  messages.value.push(toastMessage);
51
65
  }
@@ -121,4 +135,4 @@ function dialog(options) {
121
135
  }, (value) => value);
122
136
  }
123
137
  //#endregion
124
- export { messages as a, useDialog as i, owners as n, useToast as o, requests as r, useVuetifyColor as s, openDialog as t };
138
+ export { messages as a, colorToVuetifyTextColor as c, useDialog as i, owners as n, useToast as o, requests as r, useVuetifyColor as s, openDialog as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexdom/uimed-vue",
3
- "version": "2.0.0-beta.45",
3
+ "version": "2.0.0-beta.47",
4
4
  "description": "A ui framework for NEXDOM applications.",
5
5
  "keywords": [
6
6
  "lib",
@@ -52,6 +52,7 @@
52
52
  "postinstall": "playwright install --with-deps chromium"
53
53
  },
54
54
  "devDependencies": {
55
+ "@axe-core/playwright": "^4.13.0",
55
56
  "@commitlint/cli": "^21.2.1",
56
57
  "@commitlint/config-conventional": "^21.2.0",
57
58
  "@mdi/font": "^7.4.47",