Skip to content

Commit c46408e

Browse files
committed
added singleSelect props
1 parent 01deb90 commit c46408e

7 files changed

Lines changed: 226 additions & 13 deletions

File tree

README.md

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export default function App() {
9090
The `onChange` callback returns an **array of selected teeth objects**:
9191

9292
```ts
93-
type ToothSelection = {
93+
type ToothDetail = {
9494
id: string;
9595
notations: {
9696
fdi: string;
@@ -133,16 +133,151 @@ Example JSON output:
133133
| Prop | Type | Default | Description |
134134
| --- | --- | --- | --- |
135135
| `defaultSelected` | `string[]` | `[]` | Tooth IDs selected on first render. |
136-
| `onChange` | `(selectedTeeth: ToothSelection[]) => void` || Called whenever selection changes. |
136+
| `singleSelect` | `boolean` | `false` | Allow selecting only one tooth at a time (clicking the selected tooth clears it). |
137+
| `onChange` | `(selectedTeeth: ToothDetail[]) => void` || Called whenever selection changes. |
137138
| `name` | `string` | `"teeth"` | Name used for hidden form input. |
138139
| `className` | `string` | `""` | Additional class for wrapper customization. |
139140
| `theme` | `"light" \| "dark"` | `"light"` | Applies built-in light/dark palette. |
140141
| `colors` | `{ darkBlue?: string; baseBlue?: string; lightBlue?: string }` | `{}` | Override palette colors. |
141142
| `notation` | `"FDI" \| "Universal" \| "Palmer"` | `"FDI"` | Display notation in native tooth titles/tooltips. |
142-
| `tooltip` | `{ placement?: Placement; margin?: number; content?: ReactNode \| ((payload?: ToothSelection) => ReactNode) }` | `{ placement: "top", margin: 10 }` | Tooltip behavior and custom content renderer. |
143+
| `tooltip` | `{ placement?: Placement; margin?: number; content?: ReactNode \| ((payload?: ToothDetail) => ReactNode) }` | `{ placement: "top", margin: 10 }` | Tooltip behavior and custom content renderer. |
143144
| `showTooltip` | `boolean` | `true` | Enables/disables tooltip rendering. |
144145
| `showHalf` | `"full" \| "upper" \| "lower"` | `"full"` | Render full chart or only upper/lower arches. |
145146
| `maxTeeth` | `number` | `8` | Number of teeth per quadrant (for baby/mixed dentition views). |
147+
| `teethConditions` | `ToothConditionGroup[]` | `undefined` | Colorize specific teeth by condition. |
148+
| `readOnly` | `boolean` | `false` | Disable interactions and selection changes. |
149+
| `showLabels` | `boolean` | `false` | Show the condition legend under the chart. |
150+
| `layout` | `"circle" \| "square"` | `"circle"` | Render classic arch layout or square/row layout. |
151+
| `styles` | `React.CSSProperties` | `undefined` | Inline styles applied to the root container. |
152+
153+
`Placement` values:
154+
155+
```ts
156+
type Placement =
157+
| "top"
158+
| "top-start"
159+
| "top-end"
160+
| "right"
161+
| "right-start"
162+
| "right-end"
163+
| "bottom"
164+
| "bottom-start"
165+
| "bottom-end"
166+
| "left"
167+
| "left-start"
168+
| "left-end";
169+
```
170+
171+
`teethConditions` shape:
172+
173+
```ts
174+
type ToothConditionGroup = {
175+
label: string;
176+
teeth: string[]; // e.g. ["teeth-11", "teeth-12"]
177+
outlineColor: string;
178+
fillColor: string;
179+
};
180+
```
181+
182+
---
183+
184+
## 🧩 Common Recipes
185+
186+
### 1) Render a custom tooltip
187+
188+
```tsx
189+
import { Odontogram } from "react-odontogram";
190+
import "react-odontogram/style.css";
191+
192+
export default function CustomTooltipExample() {
193+
return (
194+
<Odontogram
195+
tooltip={{
196+
placement: "top",
197+
content: (payload) => (
198+
<div style={{ minWidth: 140 }}>
199+
<strong>Tooth {payload?.notations.fdi}</strong>
200+
<div>{payload?.type}</div>
201+
<small>Universal: {payload?.notations.universal}</small>
202+
</div>
203+
),
204+
}}
205+
/>
206+
);
207+
}
208+
```
209+
210+
### 2) Change theme and colors
211+
212+
```tsx
213+
import { Odontogram } from "react-odontogram";
214+
import "react-odontogram/style.css";
215+
216+
export default function ThemeExample() {
217+
return (
218+
<Odontogram
219+
className="my-odontogram"
220+
theme="dark"
221+
colors={{
222+
darkBlue: "#7c9cff",
223+
baseBlue: "#c7d2fe",
224+
lightBlue: "#4f46e5",
225+
}}
226+
/>
227+
);
228+
}
229+
```
230+
231+
```css
232+
.my-odontogram {
233+
--odontogram-tooltip-bg: #0f172a;
234+
--odontogram-tooltip-fg: #f8fafc;
235+
}
236+
```
237+
238+
### 3) Show `teethConditions` (with legend)
239+
240+
```tsx
241+
import { Odontogram } from "react-odontogram";
242+
import "react-odontogram/style.css";
243+
244+
const conditions = [
245+
{
246+
label: "caries",
247+
teeth: ["teeth-16", "teeth-26", "teeth-36"],
248+
fillColor: "#ef4444",
249+
outlineColor: "#b91c1c",
250+
},
251+
{
252+
label: "filling",
253+
teeth: ["teeth-14", "teeth-24"],
254+
fillColor: "#60a5fa",
255+
outlineColor: "#1d4ed8",
256+
},
257+
];
258+
259+
export default function ConditionsExample() {
260+
return <Odontogram teethConditions={conditions} showLabels readOnly />;
261+
}
262+
```
263+
264+
### 4) Adjust tooltip position
265+
266+
```tsx
267+
import { Odontogram } from "react-odontogram";
268+
import "react-odontogram/style.css";
269+
270+
export default function TooltipPositionExample() {
271+
return (
272+
<Odontogram
273+
tooltip={{
274+
placement: "right-start", // try: top, right, bottom-end, left-start...
275+
margin: 18, // larger = farther from tooth, smaller = closer
276+
}}
277+
/>
278+
);
279+
}
280+
```
146281

147282
---
148283

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "react-odontogram",
33
"description": "dental chart for selecting teeth",
4-
"version": "0.5.1",
4+
"version": "0.5.2",
55
"author": "Pratik Sharma <sharma.pratik2016@gmail.com>",
66
"license": "MIT",
77
"keywords": [

src/Odontogram.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export function getViewBox(layout: Layout, showHalf: ShowHalf): string {
5454

5555
export const Odontogram: FC<OdontogramProps> = ({
5656
defaultSelected = [],
57+
singleSelect = false,
5758
onChange,
5859
className = "",
5960
theme = "light",
@@ -136,7 +137,10 @@ export const Odontogram: FC<OdontogramProps> = ({
136137
* State
137138
*/
138139
const [selected, setSelected] = useState<Set<string>>(
139-
() => new Set(defaultSelected)
140+
() =>
141+
new Set(
142+
singleSelect ? defaultSelected.slice(0, 1) : defaultSelected
143+
)
140144
);
141145

142146
const svgRef = useRef<SVGSVGElement>(null);
@@ -156,13 +160,23 @@ export const Odontogram: FC<OdontogramProps> = ({
156160

157161
setSelected((previous) => {
158162
const updated = new Set(previous);
159-
updated.has(id) ? updated.delete(id) : updated.add(id);
163+
164+
if (singleSelect) {
165+
if (updated.has(id)) {
166+
updated.delete(id);
167+
} else {
168+
updated.clear();
169+
updated.add(id);
170+
}
171+
} else {
172+
updated.has(id) ? updated.delete(id) : updated.add(id);
173+
}
160174

161175
onChange?.(Array.from(updated).map(buildToothDetail));
162176
return updated;
163177
});
164178
},
165-
[onChange, readOnly, buildToothDetail]
179+
[onChange, readOnly, buildToothDetail, singleSelect]
166180
);
167181

168182
const handleKeyDown = useCallback(
@@ -335,7 +349,7 @@ export const Odontogram: FC<OdontogramProps> = ({
335349
}}
336350
role="listbox"
337351
aria-label="Odontogram"
338-
aria-multiselectable="true"
352+
aria-multiselectable={!singleSelect}
339353
data-read-only={readOnly ? "true" : "false"}
340354
>
341355
<input
@@ -389,4 +403,4 @@ export const Odontogram: FC<OdontogramProps> = ({
389403
);
390404
};
391405

392-
export default Odontogram;
406+
export default Odontogram;

src/stories/Example.stories.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export default {
3131
colors: {
3232
control: "object",
3333
},
34+
singleSelect: { control: "boolean" },
3435
onChange: { action: "changed" },
3536

3637
},
@@ -82,6 +83,15 @@ Default.args = {
8283
defaultSelected: ["teeth-11", "teeth-12", "teeth-22"],
8384
};
8485

86+
export const SingleSelect = Template.bind({});
87+
SingleSelect.args = {
88+
theme: "light",
89+
colors: {},
90+
singleSelect: true,
91+
defaultSelected: ["teeth-11"],
92+
};
93+
SingleSelect.storyName = "Single Select";
94+
8595
export const WithCustomTooltip = Template.bind({});
8696
WithCustomTooltip.args = {
8797
theme: "light",
@@ -139,4 +149,3 @@ LowerHalf.args = {
139149
LowerHalf.storyName = "LowerHalf";
140150

141151

142-

src/type.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface TeethProps {
7171
export interface OdontogramProps {
7272
name?: string;
7373
defaultSelected?: string[];
74+
singleSelect?: boolean;
7475
onChange?: (selected: ToothDetail[]) => void;
7576
className?: string;
7677
selectedColor?: string;

tests/Odontogram.test.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,60 @@ describe("Odontogram", () => {
6868
expect(onChange).toHaveBeenLastCalledWith([]);
6969
});
7070

71+
it("limits selection to one tooth in singleSelect mode", () => {
72+
const onChange = vi.fn();
73+
74+
const { container } = render(
75+
<Odontogram onChange={onChange} singleSelect={true} readOnly={false} />
76+
);
77+
78+
expect(
79+
screen.getByRole("listbox", { name: "Odontogram" })
80+
).toHaveAttribute("aria-multiselectable", "false");
81+
82+
const tooth11 = screen.getByRole("option", { name: "Tooth 11" });
83+
const tooth12 = screen.getByRole("option", { name: "Tooth 12" });
84+
85+
fireEvent.click(tooth11);
86+
expect(tooth11).toHaveAttribute("aria-selected", "true");
87+
expect(tooth12).toHaveAttribute("aria-selected", "false");
88+
89+
fireEvent.click(tooth12);
90+
expect(tooth11).toHaveAttribute("aria-selected", "false");
91+
expect(tooth12).toHaveAttribute("aria-selected", "true");
92+
93+
const hiddenInput = container.querySelector<HTMLInputElement>(
94+
"input[type='hidden'][name='teeth']"
95+
);
96+
97+
expect(hiddenInput?.value).toBe(JSON.stringify(["teeth-12"]));
98+
expect(onChange).toHaveBeenLastCalledWith([
99+
expect.objectContaining({ id: "teeth-12" }),
100+
]);
101+
102+
fireEvent.click(tooth12);
103+
expect(hiddenInput?.value).toBe(JSON.stringify([]));
104+
expect(onChange).toHaveBeenLastCalledWith([]);
105+
});
106+
107+
it("keeps only first defaultSelected tooth in singleSelect mode", () => {
108+
render(
109+
<Odontogram
110+
singleSelect={true}
111+
defaultSelected={["teeth-11", "teeth-12"]}
112+
/>
113+
);
114+
115+
expect(screen.getByRole("option", { name: "Tooth 11" })).toHaveAttribute(
116+
"aria-selected",
117+
"true"
118+
);
119+
expect(screen.getByRole("option", { name: "Tooth 12" })).toHaveAttribute(
120+
"aria-selected",
121+
"false"
122+
);
123+
});
124+
71125
it("supports keyboard selection with Enter and Space", () => {
72126
render(<Odontogram />);
73127

@@ -117,4 +171,4 @@ describe("notation helpers", () => {
117171
"--base-blue": "#2",
118172
});
119173
});
120-
});
174+
});

0 commit comments

Comments
 (0)