@startsimpli/ui 0.4.90 → 0.4.92
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/package.json +7 -1
- package/src/components/__tests__/locale-switcher.test.tsx +78 -0
- package/src/components/dashboard/DonutChart.tsx +27 -1
- package/src/components/dashboard/__tests__/display-primitives.test.tsx +29 -0
- package/src/components/i18n/LocaleMenu.tsx +26 -0
- package/src/components/i18n/LocaleSwitcher.tsx +159 -0
- package/src/components/i18n/index.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.92",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"./gantt/styles": "./src/components/gantt/gantt.css",
|
|
13
13
|
"./components": "./src/components/index.ts",
|
|
14
14
|
"./error": "./src/components/error/index.ts",
|
|
15
|
+
"./i18n": "./src/components/i18n/index.ts",
|
|
15
16
|
"./utils": "./src/utils/index.ts",
|
|
16
17
|
"./theme": "./src/theme/index.ts",
|
|
17
18
|
"./theme/contract": "./theme/contract.css",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"react": "^18.0.0 || ^19.0.0",
|
|
45
46
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
46
47
|
"@startsimpli/auth": "^0.4.34",
|
|
48
|
+
"@startsimpli/i18n": "^0.1.1",
|
|
47
49
|
"@startsimpli/api": "^0.5.54"
|
|
48
50
|
},
|
|
49
51
|
"peerDependenciesMeta": {
|
|
@@ -53,6 +55,9 @@
|
|
|
53
55
|
"@startsimpli/auth": {
|
|
54
56
|
"optional": true
|
|
55
57
|
},
|
|
58
|
+
"@startsimpli/i18n": {
|
|
59
|
+
"optional": true
|
|
60
|
+
},
|
|
56
61
|
"@startsimpli/realtime": {
|
|
57
62
|
"optional": true
|
|
58
63
|
},
|
|
@@ -115,6 +120,7 @@
|
|
|
115
120
|
"tailwindcss": "^3.4.19",
|
|
116
121
|
"tsup": "^8.5.1",
|
|
117
122
|
"typescript": "^6.0.3",
|
|
123
|
+
"@startsimpli/i18n": "0.1.1",
|
|
118
124
|
"@startsimpli/realtime": "0.0.2"
|
|
119
125
|
},
|
|
120
126
|
"keywords": [
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* startsim-768w.35.1 — the language control itself.
|
|
3
|
+
*
|
|
4
|
+
* The switcher is the one screen element that must make sense to someone who
|
|
5
|
+
* cannot read the current language. That constrains it more than a normal
|
|
6
|
+
* control: every option is written in its OWN language, each carries its own
|
|
7
|
+
* `lang`/`dir` so العربية isn't rendered by LTR rules, and the trigger is an
|
|
8
|
+
* icon rather than a word in whichever language the user is trying to leave.
|
|
9
|
+
*/
|
|
10
|
+
import { render, screen } from '@testing-library/react'
|
|
11
|
+
import userEvent from '@testing-library/user-event'
|
|
12
|
+
|
|
13
|
+
import { LocaleSwitcher, type LocaleOption } from '../i18n/LocaleSwitcher'
|
|
14
|
+
|
|
15
|
+
const locales: LocaleOption[] = [
|
|
16
|
+
{ code: 'en', name: 'English', nativeName: 'English', dir: 'ltr' },
|
|
17
|
+
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', dir: 'rtl' },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
describe('LocaleSwitcher — inline', () => {
|
|
21
|
+
it('offers every language by its own name and reports the active one', () => {
|
|
22
|
+
render(<LocaleSwitcher variant="inline" locales={locales} value="en" onChange={() => {}} />)
|
|
23
|
+
|
|
24
|
+
const english = screen.getByRole('radio', { name: 'English' })
|
|
25
|
+
const arabic = screen.getByRole('radio', { name: 'العربية' })
|
|
26
|
+
|
|
27
|
+
expect(english).toHaveAttribute('aria-checked', 'true')
|
|
28
|
+
expect(arabic).toHaveAttribute('aria-checked', 'false')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('labels each option with its own language and direction', () => {
|
|
32
|
+
render(<LocaleSwitcher variant="inline" locales={locales} value="en" onChange={() => {}} />)
|
|
33
|
+
|
|
34
|
+
const arabic = screen.getByRole('radio', { name: 'العربية' })
|
|
35
|
+
expect(arabic).toHaveAttribute('lang', 'ar')
|
|
36
|
+
expect(arabic).toHaveAttribute('dir', 'rtl')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('hands the chosen code to the caller', async () => {
|
|
40
|
+
const onChange = jest.fn()
|
|
41
|
+
render(<LocaleSwitcher variant="inline" locales={locales} value="en" onChange={onChange} />)
|
|
42
|
+
|
|
43
|
+
await userEvent.click(screen.getByRole('radio', { name: 'العربية' }))
|
|
44
|
+
expect(onChange).toHaveBeenCalledWith('ar')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('LocaleSwitcher — menu', () => {
|
|
49
|
+
it('names the trigger for screen readers and shows the active language on it', () => {
|
|
50
|
+
render(<LocaleSwitcher locales={locales} value="ar" onChange={() => {}} label="تغيير اللغة" />)
|
|
51
|
+
|
|
52
|
+
const trigger = screen.getByRole('button', { name: 'تغيير اللغة' })
|
|
53
|
+
expect(trigger).toHaveTextContent('العربية')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('lists the languages as radios and switches on select', async () => {
|
|
57
|
+
const onChange = jest.fn()
|
|
58
|
+
render(<LocaleSwitcher locales={locales} value="en" onChange={onChange} />)
|
|
59
|
+
|
|
60
|
+
await userEvent.click(screen.getByRole('button', { name: 'Change language' }))
|
|
61
|
+
|
|
62
|
+
const options = await screen.findAllByRole('menuitemradio')
|
|
63
|
+
expect(options.map((o) => o.textContent)).toEqual(['English', 'العربية'])
|
|
64
|
+
expect(options[0]).toHaveAttribute('aria-checked', 'true')
|
|
65
|
+
|
|
66
|
+
await userEvent.click(screen.getByRole('menuitemradio', { name: 'العربية' }))
|
|
67
|
+
expect(onChange).toHaveBeenCalledWith('ar')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('LocaleSwitcher — nothing to switch', () => {
|
|
72
|
+
it('renders nothing when the app ships a single language', () => {
|
|
73
|
+
const { container } = render(
|
|
74
|
+
<LocaleSwitcher locales={[locales[0]]} value="en" onChange={() => {}} />
|
|
75
|
+
)
|
|
76
|
+
expect(container).toBeEmptyDOMElement()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -22,6 +22,15 @@ export interface DonutChartProps {
|
|
|
22
22
|
showLegend?: boolean
|
|
23
23
|
/** Formats the right-hand column of a legend row. Defaults to the raw value. */
|
|
24
24
|
formatLegendValue?: (segment: DonutSegment) => React.ReactNode
|
|
25
|
+
/**
|
|
26
|
+
* Narrowest a legend column may get, in px. The legend flows into as many
|
|
27
|
+
* columns as fit, so on a wide card it packs rows side by side instead of
|
|
28
|
+
* stretching each one — a legend row is a label and a number that belong
|
|
29
|
+
* together, and a 1400px row separates them by a metre of empty card.
|
|
30
|
+
*/
|
|
31
|
+
legendColumnWidth?: number
|
|
32
|
+
/** Extra classes for the legend list — e.g. a `max-w-*` cap. */
|
|
33
|
+
legendClassName?: string
|
|
25
34
|
/** Overrides the generated description. */
|
|
26
35
|
ariaLabel?: string
|
|
27
36
|
className?: string
|
|
@@ -81,6 +90,8 @@ export function DonutChart({
|
|
|
81
90
|
thickness = 18,
|
|
82
91
|
showLegend = false,
|
|
83
92
|
formatLegendValue,
|
|
93
|
+
legendColumnWidth = 176,
|
|
94
|
+
legendClassName,
|
|
84
95
|
ariaLabel,
|
|
85
96
|
className,
|
|
86
97
|
}: DonutChartProps) {
|
|
@@ -161,7 +172,22 @@ export function DonutChart({
|
|
|
161
172
|
</div>
|
|
162
173
|
|
|
163
174
|
{showLegend && segments.length > 0 && (
|
|
164
|
-
|
|
175
|
+
// Columns, not one tall list. A legend row pairs a label with its
|
|
176
|
+
// number, so the row has to stay narrow enough for the eye to carry one
|
|
177
|
+
// to the other; `auto-fill` spends extra card width on more columns
|
|
178
|
+
// instead of on the gap inside every row.
|
|
179
|
+
<ul
|
|
180
|
+
className={cn(
|
|
181
|
+
'grid min-w-0 flex-1 gap-x-8 gap-y-2',
|
|
182
|
+
legendClassName,
|
|
183
|
+
)}
|
|
184
|
+
style={{
|
|
185
|
+
gridTemplateColumns: `repeat(auto-fill, minmax(${Math.max(
|
|
186
|
+
legendColumnWidth,
|
|
187
|
+
1,
|
|
188
|
+
)}px, 1fr))`,
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
165
191
|
{segments.map((segment, i) => (
|
|
166
192
|
<li
|
|
167
193
|
key={`${segment.label}-${i}`}
|
|
@@ -441,6 +441,35 @@ describe('DonutChart', () => {
|
|
|
441
441
|
expect(screen.getByText('$6,800')).toBeInTheDocument()
|
|
442
442
|
})
|
|
443
443
|
|
|
444
|
+
it('flows the legend into columns rather than one full-width row per segment', () => {
|
|
445
|
+
// A legend row is a label and its number, and the eye has to carry one to
|
|
446
|
+
// the other. Stretched across a wide card the pair ends up a metre apart
|
|
447
|
+
// with nothing in between, so the list is a grid that spends spare width on
|
|
448
|
+
// more columns instead of on the gap inside each row.
|
|
449
|
+
const { container } = render(<DonutChart segments={segments} showLegend />)
|
|
450
|
+
const list = container.querySelector('ul')!
|
|
451
|
+
expect(list.className).toContain('grid')
|
|
452
|
+
expect(list.style.gridTemplateColumns).toBe(
|
|
453
|
+
'repeat(auto-fill, minmax(176px, 1fr))',
|
|
454
|
+
)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('lets the caller set the column width and add classes to the legend', () => {
|
|
458
|
+
const { container } = render(
|
|
459
|
+
<DonutChart
|
|
460
|
+
segments={segments}
|
|
461
|
+
showLegend
|
|
462
|
+
legendColumnWidth={240}
|
|
463
|
+
legendClassName="max-w-md"
|
|
464
|
+
/>,
|
|
465
|
+
)
|
|
466
|
+
const list = container.querySelector('ul')!
|
|
467
|
+
expect(list.style.gridTemplateColumns).toBe(
|
|
468
|
+
'repeat(auto-fill, minmax(240px, 1fr))',
|
|
469
|
+
)
|
|
470
|
+
expect(list.className).toContain('max-w-md')
|
|
471
|
+
})
|
|
472
|
+
|
|
444
473
|
it('uses caller colours for the arcs and falls back to currentColor', () => {
|
|
445
474
|
const { container } = render(
|
|
446
475
|
<DonutChart segments={[{ label: 'a', value: 1, color: '#FF0000' }, { label: 'b', value: 1 }]} />,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { useLocale } from '@startsimpli/i18n';
|
|
5
|
+
|
|
6
|
+
import { LocaleSwitcher, type LocaleSwitcherProps } from './LocaleSwitcher';
|
|
7
|
+
|
|
8
|
+
export type LocaleMenuProps = Omit<LocaleSwitcherProps, 'locales' | 'value' | 'onChange'>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The switcher, already wired.
|
|
12
|
+
*
|
|
13
|
+
* Drop `<LocaleMenu />` anywhere inside an `<I18nProvider>` and language
|
|
14
|
+
* switching works — the provider owns the locale, persists the choice, and
|
|
15
|
+
* flips `<html dir>`. Apps should not be re-writing that three-line binding
|
|
16
|
+
* each time; `LocaleSwitcher` stays available for the rare header that needs
|
|
17
|
+
* to drive the control from somewhere else.
|
|
18
|
+
*
|
|
19
|
+
* Lives behind the `@startsimpli/ui/i18n` subpath because it imports
|
|
20
|
+
* `@startsimpli/i18n`, which is an OPTIONAL peer — apps that never touch this
|
|
21
|
+
* subpath never need the dependency.
|
|
22
|
+
*/
|
|
23
|
+
export function LocaleMenu(props: LocaleMenuProps) {
|
|
24
|
+
const { locale, setLocale, locales } = useLocale();
|
|
25
|
+
return <LocaleSwitcher {...props} locales={locales} value={locale} onChange={setLocale} />;
|
|
26
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Check, Languages } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '../../lib/utils';
|
|
7
|
+
import { Button } from '../ui/button';
|
|
8
|
+
import {
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuContent,
|
|
11
|
+
DropdownMenuItem,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from '../ui/dropdown-menu';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One selectable language. Structurally compatible with `LocaleConfig` from
|
|
17
|
+
* `@startsimpli/i18n`, but declared locally on purpose: this component stays a
|
|
18
|
+
* plain presentational control that any app can drive, i18n package or not.
|
|
19
|
+
*/
|
|
20
|
+
export interface LocaleOption {
|
|
21
|
+
code: string;
|
|
22
|
+
name: string;
|
|
23
|
+
/** The language's name in its own language — what actually goes on screen. */
|
|
24
|
+
nativeName?: string;
|
|
25
|
+
dir?: 'ltr' | 'rtl';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LocaleSwitcherProps {
|
|
29
|
+
locales: readonly LocaleOption[];
|
|
30
|
+
/** Active locale code. */
|
|
31
|
+
value: string;
|
|
32
|
+
onChange: (code: string) => void;
|
|
33
|
+
/** `menu` = icon + dropdown (any number of languages). `inline` = segmented
|
|
34
|
+
* buttons, all languages visible at once — good for exactly two. */
|
|
35
|
+
variant?: 'menu' | 'inline';
|
|
36
|
+
/** Show the active language's name beside the icon (`menu` only). */
|
|
37
|
+
showLabel?: boolean;
|
|
38
|
+
align?: 'start' | 'center' | 'end';
|
|
39
|
+
/** Accessible name for the control. Callers pass a translated string; the
|
|
40
|
+
* English default is only a floor, not a design choice. */
|
|
41
|
+
label?: string;
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What to show for a language: its own name, falling back to the English one. */
|
|
46
|
+
function displayName(locale: LocaleOption): string {
|
|
47
|
+
return locale.nativeName || locale.name;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Language picker.
|
|
52
|
+
*
|
|
53
|
+
* Presentational and controlled — it holds no locale state and imports no i18n
|
|
54
|
+
* runtime. Wire it to whatever owns the locale (`<LocaleMenu />` in this package
|
|
55
|
+
* does exactly that against `@startsimpli/i18n`).
|
|
56
|
+
*
|
|
57
|
+
* Two details that are easy to get wrong and matter here:
|
|
58
|
+
*
|
|
59
|
+
* - Every option carries its OWN `dir`. A menu rendered LTR that prints العربية
|
|
60
|
+
* without `dir="rtl"` on that item mis-orders any punctuation or digits beside
|
|
61
|
+
* it. Each language label is a little island of its own direction.
|
|
62
|
+
* - The trigger is labelled with the icon, never with a flag. Flags are
|
|
63
|
+
* countries, not languages — Arabic isn't one flag, and picking one is a
|
|
64
|
+
* political statement nobody asked the switcher to make.
|
|
65
|
+
*/
|
|
66
|
+
export function LocaleSwitcher({
|
|
67
|
+
locales,
|
|
68
|
+
value,
|
|
69
|
+
onChange,
|
|
70
|
+
variant = 'menu',
|
|
71
|
+
showLabel = true,
|
|
72
|
+
align = 'end',
|
|
73
|
+
label = 'Change language',
|
|
74
|
+
className,
|
|
75
|
+
}: LocaleSwitcherProps) {
|
|
76
|
+
// A picker with one option is a label pretending to be a control.
|
|
77
|
+
if (locales.length < 2) return null;
|
|
78
|
+
|
|
79
|
+
const active = locales.find((l) => l.code === value);
|
|
80
|
+
|
|
81
|
+
if (variant === 'inline') {
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
role="radiogroup"
|
|
85
|
+
aria-label={label}
|
|
86
|
+
dir="ltr"
|
|
87
|
+
className={cn(
|
|
88
|
+
'inline-flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5',
|
|
89
|
+
className
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{locales.map((locale) => {
|
|
93
|
+
const selected = locale.code === value;
|
|
94
|
+
return (
|
|
95
|
+
<button
|
|
96
|
+
key={locale.code}
|
|
97
|
+
type="button"
|
|
98
|
+
role="radio"
|
|
99
|
+
aria-checked={selected}
|
|
100
|
+
lang={locale.code}
|
|
101
|
+
dir={locale.dir ?? 'ltr'}
|
|
102
|
+
onClick={() => onChange(locale.code)}
|
|
103
|
+
className={cn(
|
|
104
|
+
'rounded px-2.5 py-1 text-xs font-medium transition-colors',
|
|
105
|
+
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
|
106
|
+
selected
|
|
107
|
+
? 'bg-background text-foreground shadow-sm'
|
|
108
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
{displayName(locale)}
|
|
112
|
+
</button>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<DropdownMenu>
|
|
121
|
+
<DropdownMenuTrigger asChild>
|
|
122
|
+
<Button
|
|
123
|
+
variant="ghost"
|
|
124
|
+
size="sm"
|
|
125
|
+
aria-label={label}
|
|
126
|
+
className={cn('gap-2 text-muted-foreground hover:text-foreground', className)}
|
|
127
|
+
>
|
|
128
|
+
<Languages className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
129
|
+
{showLabel && active ? (
|
|
130
|
+
<span lang={active.code} dir={active.dir ?? 'ltr'} className="text-xs font-medium">
|
|
131
|
+
{displayName(active)}
|
|
132
|
+
</span>
|
|
133
|
+
) : null}
|
|
134
|
+
</Button>
|
|
135
|
+
</DropdownMenuTrigger>
|
|
136
|
+
<DropdownMenuContent align={align} className="min-w-[10rem]">
|
|
137
|
+
{/* Radio semantics on a plain Item rather than DropdownMenuRadioItem:
|
|
138
|
+
that primitive reserves its indicator gutter with physical `pl-8`,
|
|
139
|
+
which lands on the wrong side once the document flips to RTL. */}
|
|
140
|
+
{locales.map((locale) => (
|
|
141
|
+
<DropdownMenuItem
|
|
142
|
+
key={locale.code}
|
|
143
|
+
role="menuitemradio"
|
|
144
|
+
aria-checked={locale.code === value}
|
|
145
|
+
lang={locale.code}
|
|
146
|
+
dir={locale.dir ?? 'ltr'}
|
|
147
|
+
onSelect={() => onChange(locale.code)}
|
|
148
|
+
className="gap-6"
|
|
149
|
+
>
|
|
150
|
+
<span>{displayName(locale)}</span>
|
|
151
|
+
{locale.code === value ? (
|
|
152
|
+
<Check className="ms-auto h-4 w-4 shrink-0 opacity-70" aria-hidden="true" />
|
|
153
|
+
) : null}
|
|
154
|
+
</DropdownMenuItem>
|
|
155
|
+
))}
|
|
156
|
+
</DropdownMenuContent>
|
|
157
|
+
</DropdownMenu>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language selection UI.
|
|
3
|
+
*
|
|
4
|
+
* - `LocaleSwitcher` — presentational + controlled, no i18n runtime.
|
|
5
|
+
* - `LocaleMenu` — the same control bound to `@startsimpli/i18n` (optional peer).
|
|
6
|
+
*/
|
|
7
|
+
export { LocaleSwitcher, type LocaleSwitcherProps, type LocaleOption } from './LocaleSwitcher';
|
|
8
|
+
export { LocaleMenu, type LocaleMenuProps } from './LocaleMenu';
|