Merge pull request #603 from trails-cool/feat/planner-profile-selector
feat(ui): Select primitive; restyle ProfileSelector on tokens
This commit is contained in:
commit
2bc6afcf4f
5 changed files with 122 additions and 4 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Select } from "@trails-cool/ui";
|
||||||
import type { YjsState } from "~/lib/use-yjs";
|
import type { YjsState } from "~/lib/use-yjs";
|
||||||
import { DEFAULT_PROFILE, getProfile, setProfile as setYjsProfile } from "~/lib/route-data";
|
import { DEFAULT_PROFILE, getProfile, setProfile as setYjsProfile } from "~/lib/route-data";
|
||||||
|
|
||||||
|
|
@ -34,21 +35,21 @@ export function ProfileSelector({ yjs }: ProfileSelectorProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label htmlFor="profile" className="text-sm text-gray-600">
|
<label htmlFor="profile" className="hidden text-sm text-text-md sm:block">
|
||||||
{t("profile")}:
|
{t("profile")}:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select
|
||||||
id="profile"
|
id="profile"
|
||||||
|
size="sm"
|
||||||
value={profile}
|
value={profile}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="rounded border border-gray-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
>
|
>
|
||||||
{PROFILE_IDS.map((id) => (
|
{PROFILE_IDS.map((id) => (
|
||||||
<option key={id} value={id}>
|
<option key={id} value={id}>
|
||||||
{t(`profiles.${id}`)}
|
{t(`profiles.${id}`)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
Card,
|
Card,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
|
Select,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
} from "@trails-cool/ui";
|
} from "@trails-cool/ui";
|
||||||
import { Topbar } from "~/components/Topbar";
|
import { Topbar } from "~/components/Topbar";
|
||||||
|
|
@ -123,6 +124,19 @@ export default function DevUi() {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Select">
|
||||||
|
<Select defaultValue="fastbike" size="sm" aria-label="Profile (sm)">
|
||||||
|
<option value="fastbike">Radfahren (schnell)</option>
|
||||||
|
<option value="trekking">Trekking</option>
|
||||||
|
<option value="car">Auto</option>
|
||||||
|
</Select>
|
||||||
|
<Select defaultValue="trekking" aria-label="Profile (md)">
|
||||||
|
<option value="fastbike">Radfahren (schnell)</option>
|
||||||
|
<option value="trekking">Trekking</option>
|
||||||
|
<option value="car">Auto</option>
|
||||||
|
</Select>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="IconButton">
|
<Section title="IconButton">
|
||||||
<IconButton label="Undo">
|
<IconButton label="Undo">
|
||||||
<UndoIcon />
|
<UndoIcon />
|
||||||
|
|
|
||||||
52
packages/ui/src/Select.test.tsx
Normal file
52
packages/ui/src/Select.test.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||||
|
import { Select } from "./Select.tsx";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function options() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<option value="fastbike">Fast bike</option>
|
||||||
|
<option value="trekking">Trekking</option>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Select", () => {
|
||||||
|
it("renders options and reflects the value", () => {
|
||||||
|
render(
|
||||||
|
<Select value="trekking" onChange={() => {}} aria-label="Profile">
|
||||||
|
{options()}
|
||||||
|
</Select>,
|
||||||
|
);
|
||||||
|
const select = screen.getByRole("combobox", { name: "Profile" });
|
||||||
|
expect((select as HTMLSelectElement).value).toBe("trekking");
|
||||||
|
expect(select.className).toContain("border-border");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onChange with the selected value", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<Select value="fastbike" onChange={onChange} aria-label="Profile">
|
||||||
|
{options()}
|
||||||
|
</Select>,
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByRole("combobox", { name: "Profile" }), {
|
||||||
|
target: { value: "trekking" },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the compact size classes", () => {
|
||||||
|
render(
|
||||||
|
<Select size="sm" aria-label="Profile">
|
||||||
|
{options()}
|
||||||
|
</Select>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("combobox", { name: "Profile" }).className).toContain(
|
||||||
|
"h-7",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
packages/ui/src/Select.tsx
Normal file
49
packages/ui/src/Select.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import type { SelectHTMLAttributes } from "react";
|
||||||
|
import { cn } from "./cn.ts";
|
||||||
|
|
||||||
|
export type SelectSize = "sm" | "md";
|
||||||
|
|
||||||
|
export interface SelectProps
|
||||||
|
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
|
||||||
|
size?: SelectSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base =
|
||||||
|
"appearance-none rounded-md border border-border bg-bg-raised text-text-hi " +
|
||||||
|
"transition-colors focus-visible:border-accent focus-visible:outline-none " +
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-accent-border " +
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
|
||||||
|
const sizes: Record<SelectSize, string> = {
|
||||||
|
sm: "h-7 pl-2.5 pr-7 text-xs",
|
||||||
|
md: "h-9 pl-3 pr-8 text-sm",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token-styled wrapper around a native <select>. Uses `appearance-none` plus an
|
||||||
|
* overlaid chevron so the closed control looks consistent across browsers while
|
||||||
|
* keeping native option-list behaviour and accessibility.
|
||||||
|
*/
|
||||||
|
export function Select({ size = "md", className, children, ...props }: SelectProps) {
|
||||||
|
return (
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
<select className={cn(base, sizes[size], className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-text-md"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ export { Card } from "./Card.tsx";
|
||||||
export type { CardProps } from "./Card.tsx";
|
export type { CardProps } from "./Card.tsx";
|
||||||
export { Input } from "./Input.tsx";
|
export { Input } from "./Input.tsx";
|
||||||
export type { InputProps } from "./Input.tsx";
|
export type { InputProps } from "./Input.tsx";
|
||||||
|
export { Select } from "./Select.tsx";
|
||||||
|
export type { SelectProps, SelectSize } from "./Select.tsx";
|
||||||
export { IconButton } from "./IconButton.tsx";
|
export { IconButton } from "./IconButton.tsx";
|
||||||
export type {
|
export type {
|
||||||
IconButtonProps,
|
IconButtonProps,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue