@openmrs/esm-styleguide 10.0.1-pre.5702 → 10.0.1-pre.5704

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.
@@ -1,3 +1,3 @@
1
- [0] Successfully compiled: 115 files, copied 169 files with swc (258.34ms)
1
+ [0] Successfully compiled: 115 files, copied 169 files with swc (278.14ms)
2
2
  [0] swc --strip-leading-paths --copy-files src -d dist && svgo -rf dist --quiet exited with code 0
3
3
  [1] tsc --project tsconfig.build.json exited with code 0
@@ -4,7 +4,7 @@ const diagnosisTagConfigSchema = {
4
4
  primaryColor: {
5
5
  _type: Type.String,
6
6
  _description: 'The color for displaying primary diagnoses tags',
7
- _default: 'red',
7
+ _default: 'blue',
8
8
  _validators: [
9
9
  validators.oneOf(carbonTagColors)
10
10
  ]
@@ -12,7 +12,7 @@ const diagnosisTagConfigSchema = {
12
12
  secondaryColor: {
13
13
  _type: Type.String,
14
14
  _description: 'The color for displaying secondary diagnoses tags',
15
- _default: 'blue',
15
+ _default: 'gray',
16
16
  _validators: [
17
17
  validators.oneOf(carbonTagColors)
18
18
  ]
@@ -1,13 +1,14 @@
1
1
  import React from 'react';
2
- import { type Diagnosis } from '@openmrs/esm-emr-api';
3
- interface DiagnosisTagsProps {
4
- diagnoses: Array<Diagnosis>;
2
+ import type { Diagnosis } from '@openmrs/esm-emr-api';
3
+ export interface DiagnosisTagsProps {
4
+ diagnoses: Array<Pick<Diagnosis, 'uuid' | 'display' | 'rank' | 'certainty'>>;
5
+ /**
6
+ * Mark provisional diagnoses with a leading "?". Confirmed diagnoses, and any with missing or
7
+ * unknown certainty, show just the name. Off by default because diagnoses saved before
8
+ * certainty was captured are stored as provisional.
9
+ */
10
+ showCertainty?: boolean;
5
11
  }
6
- /**
7
- * This component takes a list of diagnoses and displays them as
8
- * Carbon tags, with colors configured base on whether the diagnoses are primary
9
- * or secondary.
10
- */
12
+ /** Displays diagnoses with configured rank colours, optionally marking provisional ones. */
11
13
  export declare const DiagnosisTags: React.FC<DiagnosisTagsProps>;
12
- export {};
13
14
  //# sourceMappingURL=diagnosis-tags.component.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"diagnosis-tags.component.d.ts","sourceRoot":"","sources":["../../src/diagnosis-tags/diagnosis-tags.component.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAG1B,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAItD,UAAU,kBAAkB;IAC1B,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;CAC7B;AAED;;;;GAIG;AACH,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CAqBtD,CAAC"}
1
+ {"version":3,"file":"diagnosis-tags.component.d.ts","sourceRoot":"","sources":["../../src/diagnosis-tags/diagnosis-tags.component.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4C,MAAM,OAAO,CAAC;AAKjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAKtD,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,KAAK,CAAC,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,WAAW,CAAC,CAAC,CAAC;IAC7E;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,4FAA4F;AAC5F,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CAgBtD,CAAC"}
@@ -1,28 +1,107 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import React from "react";
3
- import { Tag } from "@carbon/react";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React, { useLayoutEffect, useRef, useState } from "react";
3
+ import { Button, Tooltip, TooltipTrigger } from "react-aria-components";
4
+ import classNames from "classnames";
5
+ import { getCoreTranslation } from "@openmrs/esm-translations";
4
6
  import { useConfig } from "@openmrs/esm-react-utils";
5
7
  import styles from "./diagnosis-tags.module.scss";
6
- /**
7
- * This component takes a list of diagnoses and displays them as
8
- * Carbon tags, with colors configured base on whether the diagnoses are primary
9
- * or secondary.
10
- */ export const DiagnosisTags = ({ diagnoses })=>{
8
+ /** Displays diagnoses with configured rank colours, optionally marking provisional ones. */ export const DiagnosisTags = ({ diagnoses, showCertainty = false })=>{
11
9
  const { diagnosisTags } = useConfig({
12
10
  externalModuleName: '@openmrs/esm-styleguide'
13
11
  });
14
12
  return /*#__PURE__*/ _jsx("div", {
15
13
  className: styles.container,
16
14
  children: diagnoses.map((diagnosis)=>{
17
- const { rank, uuid, display } = diagnosis;
18
- // technically rank can be any integer, but we only use 1 for primary
19
- // and 2 for secondary
20
- const key = rank === 1 ? 'primaryColor' : 'secondaryColor';
21
- const color = diagnosisTags[key];
22
- return /*#__PURE__*/ _jsx(Tag, {
23
- type: color,
24
- children: display
25
- }, uuid);
15
+ const isProvisional = showCertainty && diagnosis.certainty === 'PROVISIONAL';
16
+ const color = diagnosis.rank === 1 ? diagnosisTags?.primaryColor ?? 'blue' : diagnosisTags?.secondaryColor ?? 'gray';
17
+ return /*#__PURE__*/ _jsx(DiagnosisPill, {
18
+ color: color,
19
+ isProvisional: isProvisional,
20
+ name: diagnosis.display
21
+ }, diagnosis.uuid);
26
22
  })
27
23
  });
28
24
  };
25
+ function DiagnosisPill({ color, isProvisional, name }) {
26
+ const [isOpen, setIsOpen] = useState(false);
27
+ const [isFocused, setIsFocused] = useState(false);
28
+ const [isTruncated, setIsTruncated] = useState(false);
29
+ const isInteractive = isTruncated || isFocused;
30
+ const labelRef = useRef(null);
31
+ useLayoutEffect(()=>{
32
+ const label = labelRef.current;
33
+ if (!label) {
34
+ return;
35
+ }
36
+ const measure = ()=>{
37
+ const truncated = label.scrollWidth > label.clientWidth;
38
+ setIsTruncated(truncated);
39
+ if (!truncated) {
40
+ setIsOpen(false);
41
+ }
42
+ };
43
+ measure();
44
+ const observer = new ResizeObserver(measure);
45
+ observer.observe(label);
46
+ return ()=>observer.disconnect();
47
+ }, [
48
+ name,
49
+ isProvisional,
50
+ isInteractive
51
+ ]);
52
+ const className = classNames('cds--tag', 'cds--tag--md', 'cds--layout--size-md', `cds--tag--${color}`, styles.diagnosisTag);
53
+ const content = /*#__PURE__*/ _jsxs(_Fragment, {
54
+ children: [
55
+ isProvisional && /*#__PURE__*/ _jsxs(_Fragment, {
56
+ children: [
57
+ /*#__PURE__*/ _jsx("span", {
58
+ "aria-hidden": "true",
59
+ className: styles.provisionalMark,
60
+ children: "?"
61
+ }),
62
+ /*#__PURE__*/ _jsxs("span", {
63
+ className: "cds--visually-hidden",
64
+ children: [
65
+ getCoreTranslation('provisional', 'Provisional'),
66
+ " "
67
+ ]
68
+ })
69
+ ]
70
+ }),
71
+ /*#__PURE__*/ _jsx("span", {
72
+ ref: labelRef,
73
+ className: "cds--tag__label",
74
+ children: name
75
+ })
76
+ ]
77
+ });
78
+ if (!isInteractive) {
79
+ return /*#__PURE__*/ _jsx("span", {
80
+ className: className,
81
+ "data-testid": "diagnosis-tag",
82
+ children: content
83
+ });
84
+ }
85
+ return /*#__PURE__*/ _jsxs(TooltipTrigger, {
86
+ delay: 0,
87
+ closeDelay: 150,
88
+ isOpen: isOpen,
89
+ onOpenChange: setIsOpen,
90
+ children: [
91
+ /*#__PURE__*/ _jsx(Button, {
92
+ className: className,
93
+ "data-testid": "diagnosis-tag",
94
+ onFocusChange: setIsFocused,
95
+ onPress: ()=>setIsOpen(true),
96
+ children: content
97
+ }),
98
+ /*#__PURE__*/ _jsx(Tooltip, {
99
+ className: styles.tagTooltip,
100
+ placement: "bottom",
101
+ offset: 4,
102
+ containerPadding: 8,
103
+ children: name
104
+ })
105
+ ]
106
+ });
107
+ }
@@ -1,8 +1,32 @@
1
+ @use '@carbon/colors';
1
2
  @use '@carbon/layout';
3
+ @use '@carbon/type';
2
4
 
3
5
  .container {
4
6
  display: flex;
5
- flex-flow: row wrap;
7
+ flex-wrap: wrap;
6
8
  align-items: center;
7
9
  gap: layout.$spacing-02;
8
10
  }
11
+
12
+ .diagnosisTag:focus-visible {
13
+ outline: 2px solid colors.$blue-60;
14
+ outline-offset: 1px;
15
+ }
16
+
17
+ .tagTooltip {
18
+ @include type.type-style('body-compact-01');
19
+ max-width: min(18rem, calc(100vw - 1rem));
20
+ overflow-wrap: anywhere;
21
+ padding: layout.$spacing-01 layout.$spacing-03;
22
+ border-radius: 2px;
23
+ background-color: colors.$gray-80;
24
+ color: colors.$white-0;
25
+ }
26
+
27
+ // The name truncates; the "?" marking a provisional diagnosis must stay fully visible inside the tag
28
+ .provisionalMark {
29
+ margin-inline-end: layout.$spacing-01;
30
+ font-weight: 600;
31
+ flex-shrink: 0;
32
+ }
@@ -71,3 +71,27 @@ export const MultipleDiagnoses = {
71
71
  ]
72
72
  }
73
73
  };
74
+ export const WithCertainty = {
75
+ args: {
76
+ showCertainty: true,
77
+ diagnoses: [
78
+ {
79
+ uuid: '1',
80
+ display: 'Extrinsic Asthma with Asthma Attack',
81
+ rank: 1,
82
+ certainty: 'CONFIRMED'
83
+ },
84
+ {
85
+ uuid: '2',
86
+ display: 'Malaria due to Plasmodium falciparum',
87
+ rank: 2,
88
+ certainty: 'PROVISIONAL'
89
+ },
90
+ {
91
+ uuid: '3',
92
+ display: 'Anemia',
93
+ rank: 2
94
+ }
95
+ ]
96
+ }
97
+ };
package/mock-jest.tsx CHANGED
@@ -202,13 +202,18 @@ export const LocationPicker = jest.fn(({ onChange, selectedLocationUuid }) => {
202
202
  );
203
203
  });
204
204
 
205
- export const DiagnosisTags = jest.fn(({ diagnoses }: { diagnoses: Array<Diagnosis> }) => (
206
- <>
207
- {diagnoses.map((d) => (
208
- <span key={d.uuid}>{d.display}</span>
209
- ))}
210
- </>
211
- ));
205
+ export const DiagnosisTags = jest.fn(
206
+ ({ diagnoses, showCertainty = false }: { diagnoses: Array<Diagnosis>; showCertainty?: boolean }) => (
207
+ <>
208
+ {diagnoses.map((d) => (
209
+ <span key={d.uuid}>
210
+ {showCertainty && d.certainty === 'PROVISIONAL' ? '? ' : ''}
211
+ {d.display}
212
+ </span>
213
+ ))}
214
+ </>
215
+ ),
216
+ );
212
217
 
213
218
  export const Workspace2 = jest.fn(({ title, children }) => (
214
219
  <div>
package/mock.tsx CHANGED
@@ -203,13 +203,18 @@ export const LocationPicker = vi.fn(({ onChange, selectedLocationUuid }) => {
203
203
  );
204
204
  });
205
205
 
206
- export const DiagnosisTags = vi.fn(({ diagnoses }: { diagnoses: Array<Diagnosis> }) => (
207
- <>
208
- {diagnoses.map((d) => (
209
- <span key={d.uuid}>{d.display}</span>
210
- ))}
211
- </>
212
- ));
206
+ export const DiagnosisTags = vi.fn(
207
+ ({ diagnoses, showCertainty = false }: { diagnoses: Array<Diagnosis>; showCertainty?: boolean }) => (
208
+ <>
209
+ {diagnoses.map((d) => (
210
+ <span key={d.uuid}>
211
+ {showCertainty && d.certainty === 'PROVISIONAL' ? '? ' : ''}
212
+ {d.display}
213
+ </span>
214
+ ))}
215
+ </>
216
+ ),
217
+ );
213
218
 
214
219
  export const Workspace2 = vi.fn(({ title, children }) => (
215
220
  <div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-styleguide",
3
- "version": "10.0.1-pre.5702",
3
+ "version": "10.0.1-pre.5704",
4
4
  "license": "MPL-2.0",
5
5
  "description": "The styleguide for OpenMRS SPA",
6
6
  "module": "dist/internal.js",
@@ -79,18 +79,18 @@
79
79
  "uuid": "^11.1.1"
80
80
  },
81
81
  "peerDependencies": {
82
- "@openmrs/esm-api": "^10.0.1-pre.5702",
83
- "@openmrs/esm-config": "^10.0.1-pre.5702",
84
- "@openmrs/esm-emr-api": "^10.0.1-pre.5702",
85
- "@openmrs/esm-error-handling": "^10.0.1-pre.5702",
86
- "@openmrs/esm-extensions": "^10.0.1-pre.5702",
87
- "@openmrs/esm-globals": "^10.0.1-pre.5702",
88
- "@openmrs/esm-navigation": "^10.0.1-pre.5702",
89
- "@openmrs/esm-react-utils": "^10.0.1-pre.5702",
90
- "@openmrs/esm-routes": "^10.0.1-pre.5702",
91
- "@openmrs/esm-state": "^10.0.1-pre.5702",
92
- "@openmrs/esm-translations": "^10.0.1-pre.5702",
93
- "@openmrs/esm-utils": "^10.0.1-pre.5702",
82
+ "@openmrs/esm-api": "^10.0.1-pre.5704",
83
+ "@openmrs/esm-config": "^10.0.1-pre.5704",
84
+ "@openmrs/esm-emr-api": "^10.0.1-pre.5704",
85
+ "@openmrs/esm-error-handling": "^10.0.1-pre.5704",
86
+ "@openmrs/esm-extensions": "^10.0.1-pre.5704",
87
+ "@openmrs/esm-globals": "^10.0.1-pre.5704",
88
+ "@openmrs/esm-navigation": "^10.0.1-pre.5704",
89
+ "@openmrs/esm-react-utils": "^10.0.1-pre.5704",
90
+ "@openmrs/esm-routes": "^10.0.1-pre.5704",
91
+ "@openmrs/esm-state": "^10.0.1-pre.5704",
92
+ "@openmrs/esm-translations": "^10.0.1-pre.5704",
93
+ "@openmrs/esm-utils": "^10.0.1-pre.5704",
94
94
  "dayjs": "1.x",
95
95
  "i18next": "25.x",
96
96
  "react": "18.x",
@@ -99,18 +99,18 @@
99
99
  "swr": "2.x"
100
100
  },
101
101
  "devDependencies": {
102
- "@openmrs/esm-api": "10.0.1-pre.5702",
103
- "@openmrs/esm-config": "10.0.1-pre.5702",
104
- "@openmrs/esm-emr-api": "10.0.1-pre.5702",
105
- "@openmrs/esm-error-handling": "10.0.1-pre.5702",
106
- "@openmrs/esm-extensions": "10.0.1-pre.5702",
107
- "@openmrs/esm-globals": "10.0.1-pre.5702",
108
- "@openmrs/esm-navigation": "10.0.1-pre.5702",
109
- "@openmrs/esm-react-utils": "10.0.1-pre.5702",
110
- "@openmrs/esm-routes": "10.0.1-pre.5702",
111
- "@openmrs/esm-state": "10.0.1-pre.5702",
112
- "@openmrs/esm-translations": "10.0.1-pre.5702",
113
- "@openmrs/esm-utils": "10.0.1-pre.5702",
102
+ "@openmrs/esm-api": "10.0.1-pre.5704",
103
+ "@openmrs/esm-config": "10.0.1-pre.5704",
104
+ "@openmrs/esm-emr-api": "10.0.1-pre.5704",
105
+ "@openmrs/esm-error-handling": "10.0.1-pre.5704",
106
+ "@openmrs/esm-extensions": "10.0.1-pre.5704",
107
+ "@openmrs/esm-globals": "10.0.1-pre.5704",
108
+ "@openmrs/esm-navigation": "10.0.1-pre.5704",
109
+ "@openmrs/esm-react-utils": "10.0.1-pre.5704",
110
+ "@openmrs/esm-routes": "10.0.1-pre.5704",
111
+ "@openmrs/esm-state": "10.0.1-pre.5704",
112
+ "@openmrs/esm-translations": "10.0.1-pre.5704",
113
+ "@openmrs/esm-utils": "10.0.1-pre.5704",
114
114
  "@swc/cli": "0.8.1",
115
115
  "@swc/core": "1.15.21",
116
116
  "@types/geopattern": "^1.2.9",
@@ -30,13 +30,13 @@ const diagnosisTagConfigSchema: ConfigSchema = {
30
30
  primaryColor: {
31
31
  _type: Type.String,
32
32
  _description: 'The color for displaying primary diagnoses tags',
33
- _default: 'red',
33
+ _default: 'blue',
34
34
  _validators: [validators.oneOf(carbonTagColors)],
35
35
  },
36
36
  secondaryColor: {
37
37
  _type: Type.String,
38
38
  _description: 'The color for displaying secondary diagnoses tags',
39
- _default: 'blue',
39
+ _default: 'gray',
40
40
  _validators: [validators.oneOf(carbonTagColors)],
41
41
  },
42
42
  };
@@ -1,38 +1,118 @@
1
- import React from 'react';
2
- import { Tag } from '@carbon/react';
1
+ import React, { useLayoutEffect, useRef, useState } from 'react';
2
+ import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
3
+ import classNames from 'classnames';
4
+ import { getCoreTranslation } from '@openmrs/esm-translations';
3
5
  import { useConfig } from '@openmrs/esm-react-utils';
4
- import { type Diagnosis } from '@openmrs/esm-emr-api';
5
- import { type StyleguideConfigObject } from '../config-schema';
6
+ import type { Diagnosis } from '@openmrs/esm-emr-api';
7
+ import type { StyleguideConfigObject } from '../config-schema';
8
+ import type { CarbonTagColor } from '../utils';
6
9
  import styles from './diagnosis-tags.module.scss';
7
10
 
8
- interface DiagnosisTagsProps {
9
- diagnoses: Array<Diagnosis>;
11
+ export interface DiagnosisTagsProps {
12
+ diagnoses: Array<Pick<Diagnosis, 'uuid' | 'display' | 'rank' | 'certainty'>>;
13
+ /**
14
+ * Mark provisional diagnoses with a leading "?". Confirmed diagnoses, and any with missing or
15
+ * unknown certainty, show just the name. Off by default because diagnoses saved before
16
+ * certainty was captured are stored as provisional.
17
+ */
18
+ showCertainty?: boolean;
10
19
  }
11
20
 
12
- /**
13
- * This component takes a list of diagnoses and displays them as
14
- * Carbon tags, with colors configured base on whether the diagnoses are primary
15
- * or secondary.
16
- */
17
- export const DiagnosisTags: React.FC<DiagnosisTagsProps> = ({ diagnoses }) => {
18
- const { diagnosisTags } = useConfig<StyleguideConfigObject>({
19
- externalModuleName: '@openmrs/esm-styleguide',
20
- });
21
+ /** Displays diagnoses with configured rank colours, optionally marking provisional ones. */
22
+ export const DiagnosisTags: React.FC<DiagnosisTagsProps> = ({ diagnoses, showCertainty = false }) => {
23
+ const { diagnosisTags } = useConfig<StyleguideConfigObject>({ externalModuleName: '@openmrs/esm-styleguide' });
21
24
 
22
25
  return (
23
26
  <div className={styles.container}>
24
27
  {diagnoses.map((diagnosis) => {
25
- const { rank, uuid, display } = diagnosis;
26
- // technically rank can be any integer, but we only use 1 for primary
27
- // and 2 for secondary
28
- const key = rank === 1 ? 'primaryColor' : 'secondaryColor';
29
- const color = diagnosisTags[key];
28
+ const isProvisional = showCertainty && diagnosis.certainty === 'PROVISIONAL';
29
+ const color =
30
+ diagnosis.rank === 1 ? (diagnosisTags?.primaryColor ?? 'blue') : (diagnosisTags?.secondaryColor ?? 'gray');
31
+
30
32
  return (
31
- <Tag key={uuid} type={color}>
32
- {display}
33
- </Tag>
33
+ <DiagnosisPill key={diagnosis.uuid} color={color} isProvisional={isProvisional} name={diagnosis.display} />
34
34
  );
35
35
  })}
36
36
  </div>
37
37
  );
38
38
  };
39
+
40
+ interface DiagnosisPillProps {
41
+ color: CarbonTagColor;
42
+ isProvisional: boolean;
43
+ name?: string;
44
+ }
45
+
46
+ function DiagnosisPill({ color, isProvisional, name }: DiagnosisPillProps) {
47
+ const [isOpen, setIsOpen] = useState(false);
48
+ const [isFocused, setIsFocused] = useState(false);
49
+
50
+ const [isTruncated, setIsTruncated] = useState(false);
51
+ const isInteractive = isTruncated || isFocused;
52
+ const labelRef = useRef<HTMLSpanElement>(null);
53
+
54
+ useLayoutEffect(() => {
55
+ const label = labelRef.current;
56
+ if (!label) {
57
+ return;
58
+ }
59
+ const measure = () => {
60
+ const truncated = label.scrollWidth > label.clientWidth;
61
+ setIsTruncated(truncated);
62
+ if (!truncated) {
63
+ setIsOpen(false);
64
+ }
65
+ };
66
+ measure();
67
+ const observer = new ResizeObserver(measure);
68
+ observer.observe(label);
69
+ return () => observer.disconnect();
70
+ }, [name, isProvisional, isInteractive]);
71
+
72
+ const className = classNames(
73
+ 'cds--tag',
74
+ 'cds--tag--md',
75
+ 'cds--layout--size-md',
76
+ `cds--tag--${color}`,
77
+ styles.diagnosisTag,
78
+ );
79
+ const content = (
80
+ <>
81
+ {isProvisional && (
82
+ <>
83
+ <span aria-hidden="true" className={styles.provisionalMark}>
84
+ ?
85
+ </span>
86
+ <span className="cds--visually-hidden">{getCoreTranslation('provisional', 'Provisional')} </span>
87
+ </>
88
+ )}
89
+ <span ref={labelRef} className="cds--tag__label">
90
+ {name}
91
+ </span>
92
+ </>
93
+ );
94
+
95
+ if (!isInteractive) {
96
+ return (
97
+ <span className={className} data-testid="diagnosis-tag">
98
+ {content}
99
+ </span>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <TooltipTrigger delay={0} closeDelay={150} isOpen={isOpen} onOpenChange={setIsOpen}>
105
+ <Button
106
+ className={className}
107
+ data-testid="diagnosis-tag"
108
+ onFocusChange={setIsFocused}
109
+ onPress={() => setIsOpen(true)}
110
+ >
111
+ {content}
112
+ </Button>
113
+ <Tooltip className={styles.tagTooltip} placement="bottom" offset={4} containerPadding={8}>
114
+ {name}
115
+ </Tooltip>
116
+ </TooltipTrigger>
117
+ );
118
+ }
@@ -1,8 +1,32 @@
1
+ @use '@carbon/colors';
1
2
  @use '@carbon/layout';
3
+ @use '@carbon/type';
2
4
 
3
5
  .container {
4
6
  display: flex;
5
- flex-flow: row wrap;
7
+ flex-wrap: wrap;
6
8
  align-items: center;
7
9
  gap: layout.$spacing-02;
8
10
  }
11
+
12
+ .diagnosisTag:focus-visible {
13
+ outline: 2px solid colors.$blue-60;
14
+ outline-offset: 1px;
15
+ }
16
+
17
+ .tagTooltip {
18
+ @include type.type-style('body-compact-01');
19
+ max-width: min(18rem, calc(100vw - 1rem));
20
+ overflow-wrap: anywhere;
21
+ padding: layout.$spacing-01 layout.$spacing-03;
22
+ border-radius: 2px;
23
+ background-color: colors.$gray-80;
24
+ color: colors.$white-0;
25
+ }
26
+
27
+ // The name truncates; the "?" marking a provisional diagnosis must stay fully visible inside the tag
28
+ .provisionalMark {
29
+ margin-inline-end: layout.$spacing-01;
30
+ font-weight: 600;
31
+ flex-shrink: 0;
32
+ }
@@ -38,3 +38,14 @@ export const MultipleDiagnoses: Story = {
38
38
  ],
39
39
  },
40
40
  };
41
+
42
+ export const WithCertainty: Story = {
43
+ args: {
44
+ showCertainty: true,
45
+ diagnoses: [
46
+ { uuid: '1', display: 'Extrinsic Asthma with Asthma Attack', rank: 1, certainty: 'CONFIRMED' },
47
+ { uuid: '2', display: 'Malaria due to Plasmodium falciparum', rank: 2, certainty: 'PROVISIONAL' },
48
+ { uuid: '3', display: 'Anemia', rank: 2 },
49
+ ],
50
+ },
51
+ };
@@ -0,0 +1,174 @@
1
+ /** @vitest-environment jsdom */
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
4
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import { useConfig } from '@openmrs/esm-react-utils';
7
+ import { DiagnosisTags } from './diagnosis-tags.component';
8
+
9
+ const diagnoses = [
10
+ { uuid: '1', display: 'Asthma', rank: 1, certainty: 'CONFIRMED' },
11
+ { uuid: '2', display: 'Malaria', rank: 2, certainty: 'PROVISIONAL' },
12
+ { uuid: '3', display: 'Anemia', rank: 2, certainty: 'UNKNOWN' },
13
+ ];
14
+
15
+ beforeEach(() => {
16
+ vi.stubGlobal(
17
+ 'ResizeObserver',
18
+ class {
19
+ observe() {}
20
+ unobserve() {}
21
+ disconnect() {}
22
+ },
23
+ );
24
+ vi.mocked(useConfig).mockReturnValue({ diagnosisTags: { primaryColor: 'purple', secondaryColor: 'teal' } });
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.restoreAllMocks();
29
+ vi.unstubAllGlobals();
30
+ });
31
+
32
+ function mockTruncation() {
33
+ vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(300);
34
+ return vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(200);
35
+ }
36
+
37
+ test('preserves name-only display by default', () => {
38
+ render(<DiagnosisTags diagnoses={diagnoses} />);
39
+ expect(screen.getByText('Malaria')).toBeInTheDocument();
40
+ expect(screen.queryByText('?')).not.toBeInTheDocument();
41
+ expect(screen.queryByText(/provisional/i)).not.toBeInTheDocument();
42
+ });
43
+
44
+ test('marks only provisional diagnoses when enabled and preserves configured colours', () => {
45
+ render(<DiagnosisTags diagnoses={diagnoses} showCertainty />);
46
+ const [asthma, malaria, anemia] = screen.getAllByTestId('diagnosis-tag');
47
+ expect(malaria).toHaveTextContent(/^\?Provisional Malaria$/);
48
+ expect(asthma).toHaveTextContent(/^Asthma$/);
49
+ expect(anemia).toHaveTextContent(/^Anemia$/);
50
+ expect(screen.getAllByText('?')).toHaveLength(1);
51
+ expect(asthma).toHaveClass('cds--tag--purple');
52
+ expect(malaria).toHaveClass('cds--tag--teal');
53
+ });
54
+
55
+ test('defaults to blue for primary and gray for secondary diagnoses', () => {
56
+ vi.mocked(useConfig).mockReturnValue({});
57
+ render(<DiagnosisTags diagnoses={diagnoses} />);
58
+ const [asthma, malaria] = screen.getAllByTestId('diagnosis-tag');
59
+ expect(asthma).toHaveClass('cds--tag--blue');
60
+ expect(malaria).toHaveClass('cds--tag--gray');
61
+ });
62
+
63
+ test('names a truncated provisional diagnosis as provisional for assistive technology', () => {
64
+ mockTruncation();
65
+ render(<DiagnosisTags diagnoses={[diagnoses[1]]} showCertainty />);
66
+ // Browsers read this as "Provisional Malaria"; jsdom's name computation drops the space
67
+ expect(screen.getByRole('button', { name: /^Provisional\s?Malaria$/ })).toBeInTheDocument();
68
+ });
69
+
70
+ test('reveals the full name on keyboard focus and dismisses it with Escape', async () => {
71
+ mockTruncation();
72
+ const user = userEvent.setup();
73
+ render(<DiagnosisTags diagnoses={[diagnoses[0]]} showCertainty />);
74
+ await user.tab();
75
+ expect(screen.getByTestId('diagnosis-tag')).toHaveFocus();
76
+ expect(screen.getAllByText('Asthma')).toHaveLength(2);
77
+ await user.keyboard('{Escape}');
78
+ expect(screen.getAllByText('Asthma')).toHaveLength(1);
79
+ expect(screen.getByTestId('diagnosis-tag')).toHaveFocus();
80
+ });
81
+
82
+ test('allows moving onto the tooltip and dismisses a hover-open tooltip with Escape', async () => {
83
+ mockTruncation();
84
+ const user = userEvent.setup();
85
+ render(<DiagnosisTags diagnoses={[diagnoses[0]]} showCertainty />);
86
+ fireEvent.mouseMove(document.body);
87
+ fireEvent.pointerMove(document.body, { pointerType: 'mouse' });
88
+ fireEvent.pointerEnter(screen.getByTestId('diagnosis-tag'), { pointerType: 'mouse' });
89
+ fireEvent.mouseEnter(screen.getByTestId('diagnosis-tag'));
90
+ const tooltip = await screen.findByRole('tooltip');
91
+ await user.hover(tooltip);
92
+ expect(tooltip).toBeInTheDocument();
93
+ fireEvent.keyDown(document.body, { key: 'Escape' });
94
+ await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
95
+ });
96
+
97
+ test('opens the full name on activation without requiring certainty', async () => {
98
+ mockTruncation();
99
+ const user = userEvent.setup();
100
+ render(<DiagnosisTags diagnoses={[diagnoses[0]]} />);
101
+ await user.click(screen.getByRole('button', { name: 'Asthma' }));
102
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('Asthma');
103
+ expect(screen.queryByText('?')).not.toBeInTheDocument();
104
+ });
105
+
106
+ test('short names have no tab stop or tooltip', async () => {
107
+ const user = userEvent.setup();
108
+ render(<DiagnosisTags diagnoses={[diagnoses[0]]} showCertainty />);
109
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
110
+ await user.hover(screen.getByTestId('diagnosis-tag'));
111
+ await user.tab();
112
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
113
+ expect(screen.getByTestId('diagnosis-tag')).not.toHaveFocus();
114
+ });
115
+
116
+ test('updates truncation after resizing and changing certainty', () => {
117
+ const width = mockTruncation();
118
+ let resize: () => void;
119
+ const disconnect = vi.fn();
120
+ vi.stubGlobal(
121
+ 'ResizeObserver',
122
+ class {
123
+ constructor(callback: () => void) {
124
+ resize = callback;
125
+ }
126
+ observe() {}
127
+ disconnect = disconnect;
128
+ },
129
+ );
130
+ const { rerender, unmount } = render(<DiagnosisTags diagnoses={[diagnoses[1]]} showCertainty />);
131
+ expect(screen.getByRole('button')).toBeInTheDocument();
132
+ width.mockReturnValue(400);
133
+ act(() => resize());
134
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
135
+ width.mockReturnValue(200);
136
+ rerender(<DiagnosisTags diagnoses={[diagnoses[1]]} />);
137
+ expect(screen.getByRole('button')).toBeInTheDocument();
138
+ unmount();
139
+ expect(disconnect).toHaveBeenCalled();
140
+ });
141
+
142
+ test('keeps a resized pill focused until the user tabs away', async () => {
143
+ const width = mockTruncation();
144
+ let resize: () => void;
145
+ vi.stubGlobal(
146
+ 'ResizeObserver',
147
+ class {
148
+ constructor(private callback: () => void) {}
149
+ observe(element: Element) {
150
+ if (element.classList.contains('cds--tag__label')) {
151
+ resize = this.callback;
152
+ }
153
+ }
154
+ unobserve() {}
155
+ disconnect() {}
156
+ },
157
+ );
158
+ const user = userEvent.setup();
159
+ render(
160
+ <>
161
+ <DiagnosisTags diagnoses={[diagnoses[0]]} />
162
+ <button>Next</button>
163
+ </>,
164
+ );
165
+ await user.tab();
166
+ const tag = screen.getByRole('button', { name: 'Asthma' });
167
+ expect(tag).toHaveFocus();
168
+ width.mockReturnValue(400);
169
+ act(() => resize());
170
+ expect(tag).toHaveFocus();
171
+ await user.tab();
172
+ expect(screen.getByRole('button', { name: 'Next' })).toHaveFocus();
173
+ expect(screen.queryByRole('button', { name: 'Asthma' })).not.toBeInTheDocument();
174
+ });