@multiplatform.one/forms 7.8.0 → 7.11.0
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/dist/cjs/Button/index.cjs +1 -1
- package/dist/cjs/Button/index.native.js +5 -3
- package/dist/cjs/Button/index.native.js.map +1 -1
- package/dist/cjs/InputParts/index.cjs +11 -2
- package/dist/cjs/InputParts/index.native.js +11 -2
- package/dist/cjs/InputParts/index.native.js.map +1 -1
- package/dist/cjs/fields/Barcode/qr.native.js +34 -34
- package/dist/cjs/fields/FileUpload/index.cjs +6 -0
- package/dist/cjs/fields/FileUpload/index.native.js +6 -0
- package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
- package/dist/cjs/fields/Icon/drawnSet.cjs +3 -1
- package/dist/cjs/fields/Icon/drawnSet.native.js +3 -1
- package/dist/cjs/fields/Icon/drawnSet.native.js.map +1 -1
- package/dist/cjs/fields/RadioGroup/index.cjs +34 -5
- package/dist/cjs/fields/RadioGroup/index.native.js +49 -7
- package/dist/cjs/fields/RadioGroup/index.native.js.map +1 -1
- package/dist/cjs/fields/ToggleGroup/index.cjs +35 -2
- package/dist/cjs/fields/ToggleGroup/index.native.js +42 -2
- package/dist/cjs/fields/ToggleGroup/index.native.js.map +1 -1
- package/dist/cjs/shared/nativeA11y.cjs +57 -0
- package/dist/cjs/shared/nativeA11y.native.js +61 -0
- package/dist/cjs/shared/nativeA11y.native.js.map +1 -0
- package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
- package/dist/esm/Button/index.mjs +1 -1
- package/dist/esm/Button/index.mjs.map +1 -1
- package/dist/esm/Button/index.native.js +5 -3
- package/dist/esm/Button/index.native.js.map +1 -1
- package/dist/esm/Chip/index.native.js.map +1 -1
- package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
- package/dist/esm/ContextualSaveBar/clientKnownIdentity.native.js.map +1 -1
- package/dist/esm/ContextualSaveBar/commitImmediateField.native.js.map +1 -1
- package/dist/esm/ContextualSaveBar/index.native.js.map +1 -1
- package/dist/esm/ContextualSaveBar/useUnsavedChangesGuard.native.js.map +1 -1
- package/dist/esm/ControlGroup/index.native.js.map +1 -1
- package/dist/esm/DescriptionList/index.native.js.map +1 -1
- package/dist/esm/ErrorSummary.native.js.map +1 -1
- package/dist/esm/FieldDisplay/editMode.native.js.map +1 -1
- package/dist/esm/FieldDisplay/formatters.native.js.map +1 -1
- package/dist/esm/FieldDisplay/index.native.js.map +1 -1
- package/dist/esm/FieldGroup.native.js.map +1 -1
- package/dist/esm/FloatingPanel/nestedAction.native.js.map +1 -1
- package/dist/esm/FloatingPanel/useFloatingPanel.native.js.map +1 -1
- package/dist/esm/Form/index.native.js.map +1 -1
- package/dist/esm/Form/validateOn.native.js.map +1 -1
- package/dist/esm/FormGrid.native.js.map +1 -1
- package/dist/esm/FormSection.native.js.map +1 -1
- package/dist/esm/InputParts/index.mjs +11 -2
- package/dist/esm/InputParts/index.mjs.map +1 -1
- package/dist/esm/InputParts/index.native.js +11 -2
- package/dist/esm/InputParts/index.native.js.map +1 -1
- package/dist/esm/Label/index.native.js.map +1 -1
- package/dist/esm/Skeleton/index.native.js.map +1 -1
- package/dist/esm/Spinner/SpinnerGlyph.native.js.map +1 -1
- package/dist/esm/Spinner/spinnerSize.native.js.map +1 -1
- package/dist/esm/Table/index.native.js.map +1 -1
- package/dist/esm/Wheel/haptic.native.js.map +1 -1
- package/dist/esm/Wheel/index.native.js.map +1 -1
- package/dist/esm/actionLabel.native.js.map +1 -1
- package/dist/esm/fieldDefaults.native.js.map +1 -1
- package/dist/esm/fieldLayout.native.js.map +1 -1
- package/dist/esm/fields/Barcode/BarcodePreview.native.js.map +1 -1
- package/dist/esm/fields/Barcode/code128.native.js.map +1 -1
- package/dist/esm/fields/Barcode/index.native.js.map +1 -1
- package/dist/esm/fields/Barcode/qr.native.js +34 -34
- package/dist/esm/fields/Barcode/qr.native.js.map +1 -1
- package/dist/esm/fields/Barcode/scannerTypes.native.js.map +1 -1
- package/dist/esm/fields/Checkbox/Checkbox.native.js.map +1 -1
- package/dist/esm/fields/Checkbox/CheckboxBox.native.js.map +1 -1
- package/dist/esm/fields/Checkbox/CheckboxCard.native.js.map +1 -1
- package/dist/esm/fields/Checkbox/CheckboxFieldFrame.native.js.map +1 -1
- package/dist/esm/fields/Checkbox/CheckboxGroup.native.js.map +1 -1
- package/dist/esm/fields/Checkbox/checkboxGroupContext.native.js.map +1 -1
- package/dist/esm/fields/Checkbox/index.native.js.map +1 -1
- package/dist/esm/fields/ColorPicker/index.native.js.map +1 -1
- package/dist/esm/fields/ColorPicker/parts.native.js.map +1 -1
- package/dist/esm/fields/Combobox/authorOptions.native.js.map +1 -1
- package/dist/esm/fields/Combobox/index.native.js.map +1 -1
- package/dist/esm/fields/DatePicker/Calendar.native.js.map +1 -1
- package/dist/esm/fields/DatePicker/DateRangePicker.native.js.map +1 -1
- package/dist/esm/fields/DatePicker/DatetimePicker.catalog.native.js.map +1 -1
- package/dist/esm/fields/DatePicker/MonthPicker.native.js.map +1 -1
- package/dist/esm/fields/DatePicker/MultiDatePicker.native.js.map +1 -1
- package/dist/esm/fields/DatePicker/catalog.native.js.map +1 -1
- package/dist/esm/fields/DatePicker/fieldValueSync.native.js.map +1 -1
- package/dist/esm/fields/DatePicker/parts.native.js.map +1 -1
- package/dist/esm/fields/DatePicker/utils.native.js.map +1 -1
- package/dist/esm/fields/Duration/durationMath.native.js.map +1 -1
- package/dist/esm/fields/Duration/index.native.js.map +1 -1
- package/dist/esm/fields/FileUpload/filePickerTypes.native.js.map +1 -1
- package/dist/esm/fields/FileUpload/index.mjs +6 -0
- package/dist/esm/fields/FileUpload/index.mjs.map +1 -1
- package/dist/esm/fields/FileUpload/index.native.js +6 -0
- package/dist/esm/fields/FileUpload/index.native.js.map +1 -1
- package/dist/esm/fields/FileUpload/nativePickerLoader.native.js.map +1 -1
- package/dist/esm/fields/Geolocation/expoMapsLoader.native.js.map +1 -1
- package/dist/esm/fields/Geolocation/geoValue.native.js.map +1 -1
- package/dist/esm/fields/Geolocation/geocoder.native.js.map +1 -1
- package/dist/esm/fields/Geolocation/index.native.js.map +1 -1
- package/dist/esm/fields/Geolocation/mapMath.native.js.map +1 -1
- package/dist/esm/fields/Geolocation/mapTypes.native.js.map +1 -1
- package/dist/esm/fields/Geolocation/nativeSearchFocus.native.js.map +1 -1
- package/dist/esm/fields/Icon/drawnSet.mjs +3 -1
- package/dist/esm/fields/Icon/drawnSet.mjs.map +1 -1
- package/dist/esm/fields/Icon/drawnSet.native.js +3 -1
- package/dist/esm/fields/Icon/drawnSet.native.js.map +1 -1
- package/dist/esm/fields/Icon/index.native.js.map +1 -1
- package/dist/esm/fields/ImageField/index.native.js.map +1 -1
- package/dist/esm/fields/Input/index.native.js.map +1 -1
- package/dist/esm/fields/MentionInput/index.native.js.map +1 -1
- package/dist/esm/fields/OTPInput/index.native.js.map +1 -1
- package/dist/esm/fields/OTPInput/otpCellCss.native.js.map +1 -1
- package/dist/esm/fields/PhoneInput/dialCodes.native.js.map +1 -1
- package/dist/esm/fields/PhoneInput/index.native.js.map +1 -1
- package/dist/esm/fields/PhoneInput/phone.native.js.map +1 -1
- package/dist/esm/fields/Progress/index.native.js.map +1 -1
- package/dist/esm/fields/RadioGroup/index.mjs +34 -5
- package/dist/esm/fields/RadioGroup/index.mjs.map +1 -1
- package/dist/esm/fields/RadioGroup/index.native.js +49 -7
- package/dist/esm/fields/RadioGroup/index.native.js.map +1 -1
- package/dist/esm/fields/Rating/index.native.js.map +1 -1
- package/dist/esm/fields/Rating/ratingMath.native.js.map +1 -1
- package/dist/esm/fields/Rating/types.native.js.map +1 -1
- package/dist/esm/fields/SearchInput/index.native.js.map +1 -1
- package/dist/esm/fields/Select/index.native.js.map +1 -1
- package/dist/esm/fields/Signature/index.native.js.map +1 -1
- package/dist/esm/fields/Slider/index.native.js.map +1 -1
- package/dist/esm/fields/Slider/jumpMotion.native.js.map +1 -1
- package/dist/esm/fields/Stepper/index.native.js.map +1 -1
- package/dist/esm/fields/Stepper/resolveStepperPlacement.native.js.map +1 -1
- package/dist/esm/fields/Switch/index.native.js.map +1 -1
- package/dist/esm/fields/TextArea/index.native.js.map +1 -1
- package/dist/esm/fields/TimePicker/catalog.native.js.map +1 -1
- package/dist/esm/fields/TimePicker/parts.native.js.map +1 -1
- package/dist/esm/fields/TimeRangeScrubber/geometry.native.js.map +1 -1
- package/dist/esm/fields/TimeRangeScrubber/index.native.js.map +1 -1
- package/dist/esm/fields/ToggleGroup/index.mjs +35 -2
- package/dist/esm/fields/ToggleGroup/index.mjs.map +1 -1
- package/dist/esm/fields/ToggleGroup/index.native.js +42 -2
- package/dist/esm/fields/ToggleGroup/index.native.js.map +1 -1
- package/dist/esm/fields/nativeDateTime/OsDateTimeSheet.native.js.map +1 -1
- package/dist/esm/fields/nativeDateTime/osDateTimeIosDisplay.native.js.map +1 -1
- package/dist/esm/fields/nativeSelect.native.js.map +1 -1
- package/dist/esm/fields/selectedOrder.native.js.map +1 -1
- package/dist/esm/formSpacing.native.js.map +1 -1
- package/dist/esm/hooks/useFocusManagement.native.js.map +1 -1
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/requiredMarking.native.js.map +1 -1
- package/dist/esm/shared/TimeColumn.native.js.map +1 -1
- package/dist/esm/shared/capabilityPreflight.native.js.map +1 -1
- package/dist/esm/shared/colorRamps.native.js.map +1 -1
- package/dist/esm/shared/devWarn.native.js.map +1 -1
- package/dist/esm/shared/floatingList.native.js.map +1 -1
- package/dist/esm/shared/focusManagement.native.js.map +1 -1
- package/dist/esm/shared/groupContext.native.js.map +1 -1
- package/dist/esm/shared/i18n.native.js.map +1 -1
- package/dist/esm/shared/nativeA11y.mjs +31 -0
- package/dist/esm/shared/nativeA11y.mjs.map +1 -0
- package/dist/esm/shared/nativeA11y.native.js +32 -0
- package/dist/esm/shared/nativeA11y.native.js.map +1 -0
- package/dist/esm/shared/pickerTriggerContract.native.js.map +1 -1
- package/dist/esm/shared/pressSlopProps.native.js.map +1 -1
- package/dist/esm/shared/t.native.js.map +1 -1
- package/dist/esm/shared/tableCellContext.native.js.map +1 -1
- package/dist/esm/shared/useDragScroll.native.js.map +1 -1
- package/dist/esm/shared/useKeyboardInset.native.js.map +1 -1
- package/dist/esm/shared/useSheetEscapeDismiss.native.js.map +1 -1
- package/dist/esm/shared/useWheelIncrement.native.js.map +1 -1
- package/dist/esm/shared/utils.native.js.map +1 -1
- package/dist/esm/shared/zIndex.native.js.map +1 -1
- package/dist/esm/themedPrimitives.native.js.map +1 -1
- package/dist/esm/types.native.js.map +1 -1
- package/dist/esm/utils/LinkCell.native.js.map +1 -1
- package/dist/jsx/Button/index.mjs +1 -1
- package/dist/jsx/Button/index.mjs.map +1 -1
- package/dist/jsx/Button/index.native.js +5 -3
- package/dist/jsx/Button/index.native.js.map +1 -1
- package/dist/jsx/InputParts/index.mjs +11 -2
- package/dist/jsx/InputParts/index.mjs.map +1 -1
- package/dist/jsx/InputParts/index.native.js +11 -2
- package/dist/jsx/InputParts/index.native.js.map +1 -1
- package/dist/jsx/fields/Barcode/qr.native.js +34 -34
- package/dist/jsx/fields/FileUpload/index.mjs +6 -0
- package/dist/jsx/fields/FileUpload/index.mjs.map +1 -1
- package/dist/jsx/fields/FileUpload/index.native.js +6 -0
- package/dist/jsx/fields/FileUpload/index.native.js.map +1 -1
- package/dist/jsx/fields/Icon/drawnSet.mjs +3 -1
- package/dist/jsx/fields/Icon/drawnSet.mjs.map +1 -1
- package/dist/jsx/fields/Icon/drawnSet.native.js +3 -1
- package/dist/jsx/fields/Icon/drawnSet.native.js.map +1 -1
- package/dist/jsx/fields/RadioGroup/index.mjs +34 -5
- package/dist/jsx/fields/RadioGroup/index.mjs.map +1 -1
- package/dist/jsx/fields/RadioGroup/index.native.js +49 -7
- package/dist/jsx/fields/RadioGroup/index.native.js.map +1 -1
- package/dist/jsx/fields/ToggleGroup/index.mjs +35 -2
- package/dist/jsx/fields/ToggleGroup/index.mjs.map +1 -1
- package/dist/jsx/fields/ToggleGroup/index.native.js +42 -2
- package/dist/jsx/fields/ToggleGroup/index.native.js.map +1 -1
- package/dist/jsx/shared/nativeA11y.mjs +31 -0
- package/dist/jsx/shared/nativeA11y.mjs.map +1 -0
- package/dist/jsx/shared/nativeA11y.native.js +61 -0
- package/dist/jsx/shared/nativeA11y.native.js.map +1 -0
- package/package.json +11 -10
- package/src/Button/Button.fontWeight.spec.tsx +75 -0
- package/src/Button/index.tsx +5 -3
- package/src/FieldGroup.stories.tsx +3 -7
- package/src/FieldLayout.stories.tsx +34 -12
- package/src/Fieldset.stories.tsx +3 -7
- package/src/FormGrid.stories.tsx +5 -11
- package/src/InputParts/InputButton.spec.tsx +16 -0
- package/src/InputParts/index.tsx +19 -2
- package/src/fieldFamilyStories.radius.spec.tsx +118 -0
- package/src/fields/Barcode/Barcode.spec.tsx +2 -0
- package/src/fields/FileUpload/FileUpload.spec.tsx +29 -0
- package/src/fields/FileUpload/index.tsx +6 -0
- package/src/fields/Icon/drawnSet.tsx +7 -2
- package/src/fields/RadioGroup/RadioGroup.native.spec.tsx +67 -0
- package/src/fields/RadioGroup/index.tsx +82 -8
- package/src/fields/ToggleGroup/ToggleGroup.native.spec.tsx +73 -0
- package/src/fields/ToggleGroup/ToggleGroup.spec.tsx +49 -0
- package/src/fields/ToggleGroup/index.tsx +76 -1
- package/src/shared/nativeA11y.spec.ts +64 -0
- package/src/shared/nativeA11y.ts +80 -0
- package/types/Button/index.d.ts.map +1 -1
- package/types/InputParts/index.d.ts.map +1 -1
- package/types/fields/FileUpload/index.d.ts.map +1 -1
- package/types/fields/Icon/drawnSet.d.ts.map +1 -1
- package/types/fields/RadioGroup/index.d.ts +28 -1
- package/types/fields/RadioGroup/index.d.ts.map +1 -1
- package/types/fields/ToggleGroup/index.d.ts +30 -1
- package/types/fields/ToggleGroup/index.d.ts.map +1 -1
- package/types/shared/nativeA11y.d.ts +61 -0
- package/types/shared/nativeA11y.d.ts.map +1 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* MPO-125: the `fontWeight` knob must reach the Button LABEL. A literal
|
|
5
|
+
* `fontWeight="400"` sat after the `knobProps.body` spread and pinned the
|
|
6
|
+
* label at 400 in both schemes, while Input and Select moved to 700 off the
|
|
7
|
+
* same knob. `docs/design/knob-laws.md` § Family rules already says
|
|
8
|
+
* `button | ... | fonts honour`, so the render was the thing that was wrong.
|
|
9
|
+
*
|
|
10
|
+
* The weight is read off the TEXT NODE, not the frame — the frame reported
|
|
11
|
+
* 400 either way, which is how the defect survived. jsdom cannot cascade
|
|
12
|
+
* Tamagui CSS, so the rendered value is the atomic class the text node emits
|
|
13
|
+
* (`_fow-700`); it exists only when the prop survived the merge, which is the
|
|
14
|
+
* thing under test. Same measurement Button.labelInk.spec.tsx uses.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
18
|
+
import { Preset } from "@multiplatform.one/theme";
|
|
19
|
+
import { cleanup, screen } from "@testing-library/react";
|
|
20
|
+
import { Theme } from "tamagui";
|
|
21
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
22
|
+
import { Button } from "./index";
|
|
23
|
+
|
|
24
|
+
afterEach(cleanup);
|
|
25
|
+
|
|
26
|
+
const SCHEMES = ["light", "dark"] as const;
|
|
27
|
+
|
|
28
|
+
function inkWeight(el: HTMLElement): string {
|
|
29
|
+
const inline = el.style.fontWeight;
|
|
30
|
+
if (inline) return String(inline);
|
|
31
|
+
for (const cls of String(el.className ?? "").split(/\s+/)) {
|
|
32
|
+
const match = cls.match(/^_fow-(\d+)$/);
|
|
33
|
+
if (match) return match[1];
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function labelNode(
|
|
39
|
+
fontWeight: "regular" | "bold",
|
|
40
|
+
scheme: (typeof SCHEMES)[number],
|
|
41
|
+
label: string,
|
|
42
|
+
): HTMLElement {
|
|
43
|
+
renderWithProviders(
|
|
44
|
+
<Theme name={scheme}>
|
|
45
|
+
<Preset overrides={{ fontWeight }}>
|
|
46
|
+
<Button>{label}</Button>
|
|
47
|
+
</Preset>
|
|
48
|
+
</Theme>,
|
|
49
|
+
);
|
|
50
|
+
const node = screen.getByText(label) as HTMLElement;
|
|
51
|
+
expect(node.tagName).not.toBe("BUTTON");
|
|
52
|
+
return node;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("Button MPO-125 fontWeight knob", () => {
|
|
56
|
+
it.each(SCHEMES)("%s: bold takes the label to 700", (scheme) => {
|
|
57
|
+
const node = labelNode("bold", scheme, `bold-${scheme}`);
|
|
58
|
+
expect(inkWeight(node)).toBe("700");
|
|
59
|
+
expect(node.className).not.toMatch(/_fow-400\b/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it.each(SCHEMES)("%s: regular keeps the label at 400", (scheme) => {
|
|
63
|
+
const node = labelNode("regular", scheme, `regular-${scheme}`);
|
|
64
|
+
expect(inkWeight(node)).toBe("400");
|
|
65
|
+
expect(node.className).not.toMatch(/_fow-700\b/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each(SCHEMES)("%s: the size variant never leaks 500/600 at either stop", (scheme) => {
|
|
69
|
+
for (const stop of ["regular", "bold"] as const) {
|
|
70
|
+
const node = labelNode(stop, scheme, `leak-${stop}-${scheme}`);
|
|
71
|
+
expect(node.className).not.toMatch(/_fow-500\b/);
|
|
72
|
+
expect(node.className).not.toMatch(/_fow-600\b/);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/Button/index.tsx
CHANGED
|
@@ -545,9 +545,11 @@ export function Button({
|
|
|
545
545
|
// LC-40: on the washed disabled chrome the label leaves the on-fill
|
|
546
546
|
// foreground and pins to the lowest AA tier (keepLabel keeps >=4.5:1).
|
|
547
547
|
{...(isDisabled ? disabledState.textKnobProps : undefined)}
|
|
548
|
-
// Boards measure ink.weight on the TEXT NODE, never the frame.
|
|
549
|
-
//
|
|
550
|
-
|
|
548
|
+
// Boards measure ink.weight on the TEXT NODE, never the frame. The
|
|
549
|
+
// prop is restated after the spreads because SizableText's size
|
|
550
|
+
// variant otherwise leaks 500/600 over it (MPO-125: a literal 400
|
|
551
|
+
// here pinned the label and swallowed the fontWeight knob).
|
|
552
|
+
fontWeight={knobProps.body.fontWeight}
|
|
551
553
|
transition={knobProps.transition}
|
|
552
554
|
>
|
|
553
555
|
{label}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
|
|
2
|
-
import {
|
|
2
|
+
import { YStack } from "tamagui";
|
|
3
3
|
import { FieldGroup } from "./FieldGroup";
|
|
4
4
|
import { FormSection, FormSectionStack } from "./FormSection";
|
|
5
|
-
import {
|
|
5
|
+
import { Input } from "./fields/Input";
|
|
6
6
|
|
|
7
7
|
const meta: Meta<typeof FieldGroup> = {
|
|
8
8
|
title: "Forms/FieldGroup",
|
|
@@ -18,11 +18,7 @@ export default meta;
|
|
|
18
18
|
type Story = StoryObj<typeof FieldGroup>;
|
|
19
19
|
|
|
20
20
|
function Field({ id, label }: { id: string; label: string }) {
|
|
21
|
-
return
|
|
22
|
-
<FieldLayout id={id} label={label}>
|
|
23
|
-
<TamaguiInput id={id} placeholder={label} />
|
|
24
|
-
</FieldLayout>
|
|
25
|
-
);
|
|
21
|
+
return <Input id={id} label={label} placeholder={label} />;
|
|
26
22
|
}
|
|
27
23
|
|
|
28
24
|
export const Basic: Story = {
|
|
@@ -3,13 +3,35 @@ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
|
|
|
3
3
|
import { useForm } from "@tanstack/react-form";
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
import { Preset } from "@multiplatform.one/theme";
|
|
6
|
-
import {
|
|
6
|
+
import { Paragraph, YStack, XStack } from "tamagui";
|
|
7
7
|
import { Button } from "./Button";
|
|
8
8
|
import { Form } from "./Form";
|
|
9
9
|
import { Field, FieldLayout } from "./fieldLayout";
|
|
10
10
|
import { Input } from "./fields/Input";
|
|
11
|
+
import { Input as InputParts } from "./InputParts";
|
|
11
12
|
import { Checkbox } from "./fields/Checkbox";
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* FieldLayout wraps a CONTROL, so these stories cannot mount the house `Input`
|
|
16
|
+
* field — it brings its own FieldLayout. `InputParts.Box` is the house chrome
|
|
17
|
+
* underneath that field, and it is the part that consumes the knobs.
|
|
18
|
+
*/
|
|
19
|
+
function Control(props: {
|
|
20
|
+
id?: string;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
value?: string;
|
|
23
|
+
onChangeText?: (text: string) => void;
|
|
24
|
+
secureTextEntry?: boolean;
|
|
25
|
+
}) {
|
|
26
|
+
return (
|
|
27
|
+
<InputParts>
|
|
28
|
+
<InputParts.Box>
|
|
29
|
+
<InputParts.Area {...props} />
|
|
30
|
+
</InputParts.Box>
|
|
31
|
+
</InputParts>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
13
35
|
const meta: Meta<typeof FieldLayout> = {
|
|
14
36
|
title: "Forms/FieldLayout",
|
|
15
37
|
component: FieldLayout,
|
|
@@ -37,7 +59,7 @@ export const Basic: Story = {
|
|
|
37
59
|
render: (args) => (
|
|
38
60
|
<YStack maxWidth={360}>
|
|
39
61
|
<FieldLayout {...args}>
|
|
40
|
-
<
|
|
62
|
+
<Control id={args.id} placeholder="you@example.com" />
|
|
41
63
|
</FieldLayout>
|
|
42
64
|
</YStack>
|
|
43
65
|
),
|
|
@@ -90,7 +112,7 @@ export const ErrorStates: Story = {
|
|
|
90
112
|
// DG-COPY-02: error copy states the rule broken — never "invalid"/"please"/"error".
|
|
91
113
|
error="Value must use letters only"
|
|
92
114
|
>
|
|
93
|
-
<
|
|
115
|
+
<Control id="fl-err-string" />
|
|
94
116
|
</FieldLayout>
|
|
95
117
|
<FieldLayout
|
|
96
118
|
id="fl-err-bool"
|
|
@@ -98,7 +120,7 @@ export const ErrorStates: Story = {
|
|
|
98
120
|
helperText="Helper should remain visible"
|
|
99
121
|
error={true}
|
|
100
122
|
>
|
|
101
|
-
<
|
|
123
|
+
<Control id="fl-err-bool" />
|
|
102
124
|
</FieldLayout>
|
|
103
125
|
<FieldLayout
|
|
104
126
|
id="fl-err-empty"
|
|
@@ -106,7 +128,7 @@ export const ErrorStates: Story = {
|
|
|
106
128
|
helperText="Empty string should act as no message"
|
|
107
129
|
error=""
|
|
108
130
|
>
|
|
109
|
-
<
|
|
131
|
+
<Control id="fl-err-empty" />
|
|
110
132
|
</FieldLayout>
|
|
111
133
|
</YStack>
|
|
112
134
|
),
|
|
@@ -116,13 +138,13 @@ export const AnatomyEdgeCases: Story = {
|
|
|
116
138
|
render: () => (
|
|
117
139
|
<YStack gap="$6" maxWidth={360}>
|
|
118
140
|
<FieldLayout id="fl-required" label="Required field" required>
|
|
119
|
-
<
|
|
141
|
+
<Control id="fl-required" />
|
|
120
142
|
</FieldLayout>
|
|
121
143
|
<FieldLayout id="fl-nolabel" helperText="No label means compact $1 gap">
|
|
122
|
-
<
|
|
144
|
+
<Control id="fl-nolabel" />
|
|
123
145
|
</FieldLayout>
|
|
124
146
|
<FieldLayout id="fl-compact" label="Compact spacing" compactSpacing helperText="Compact gap">
|
|
125
|
-
<
|
|
147
|
+
<Control id="fl-compact" />
|
|
126
148
|
</FieldLayout>
|
|
127
149
|
<FieldLayout
|
|
128
150
|
id="fl-node-label"
|
|
@@ -133,14 +155,14 @@ export const AnatomyEdgeCases: Story = {
|
|
|
133
155
|
</XStack>
|
|
134
156
|
}
|
|
135
157
|
>
|
|
136
|
-
<
|
|
158
|
+
<Control id="fl-node-label" />
|
|
137
159
|
</FieldLayout>
|
|
138
160
|
<FieldLayout
|
|
139
161
|
id="fl-long"
|
|
140
162
|
label="Long messages"
|
|
141
163
|
error="This validation message is extremely long and keeps going to make sure long validation copy wraps correctly under the control without pushing the layout sideways or clipping any of its content at narrow widths"
|
|
142
164
|
>
|
|
143
|
-
<
|
|
165
|
+
<Control id="fl-long" />
|
|
144
166
|
</FieldLayout>
|
|
145
167
|
</YStack>
|
|
146
168
|
),
|
|
@@ -166,7 +188,7 @@ export const FieldWrapper: Story = {
|
|
|
166
188
|
helperText="TanStack Field wrapper"
|
|
167
189
|
field={field as any}
|
|
168
190
|
>
|
|
169
|
-
<
|
|
191
|
+
<Control
|
|
170
192
|
id="field-username"
|
|
171
193
|
value={String(field.state.value ?? "")}
|
|
172
194
|
onChangeText={(text) => field.handleChange(text)}
|
|
@@ -208,7 +230,7 @@ export const FieldRequiredValidator: Story = {
|
|
|
208
230
|
>
|
|
209
231
|
{(field) => (
|
|
210
232
|
<FieldLayout id="field-nickname" label="Nickname" required field={field as any}>
|
|
211
|
-
<
|
|
233
|
+
<Control
|
|
212
234
|
id="field-nickname"
|
|
213
235
|
value={String(field.state.value ?? "")}
|
|
214
236
|
onChangeText={(text) => field.handleChange(text)}
|
package/src/Fieldset.stories.tsx
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
|
|
2
|
-
import {
|
|
3
|
-
import { FieldLayout } from "./fieldLayout";
|
|
2
|
+
import { YStack } from "tamagui";
|
|
4
3
|
import { Fieldset, FormSectionStack } from "./FormSection";
|
|
4
|
+
import { Input } from "./fields/Input";
|
|
5
5
|
|
|
6
6
|
function Field({ id, label, ...rest }: { id: string; label: string; secureTextEntry?: boolean }) {
|
|
7
|
-
return
|
|
8
|
-
<FieldLayout id={id} label={label}>
|
|
9
|
-
<TamaguiInput id={id} placeholder={label} {...rest} />
|
|
10
|
-
</FieldLayout>
|
|
11
|
-
);
|
|
7
|
+
return <Input id={id} label={label} placeholder={label} {...rest} />;
|
|
12
8
|
}
|
|
13
9
|
|
|
14
10
|
const meta: Meta<typeof Fieldset> = {
|
package/src/FormGrid.stories.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
|
|
2
|
-
import {
|
|
2
|
+
import { Paragraph, YStack } from "tamagui";
|
|
3
3
|
import { FieldGroup } from "./FieldGroup";
|
|
4
4
|
import { FormGrid } from "./FormGrid";
|
|
5
5
|
import { FormSection, FormSectionStack } from "./FormSection";
|
|
6
|
-
import {
|
|
6
|
+
import { Input } from "./fields/Input";
|
|
7
7
|
|
|
8
8
|
const meta: Meta<typeof FormGrid> = {
|
|
9
9
|
title: "Forms/FormGrid",
|
|
@@ -24,11 +24,7 @@ export default meta;
|
|
|
24
24
|
type Story = StoryObj<typeof FormGrid>;
|
|
25
25
|
|
|
26
26
|
function GridField({ id, label }: { id: string; label: string }) {
|
|
27
|
-
return
|
|
28
|
-
<FieldLayout id={id} label={label}>
|
|
29
|
-
<TamaguiInput id={id} placeholder={label} />
|
|
30
|
-
</FieldLayout>
|
|
31
|
-
);
|
|
27
|
+
return <Input id={id} label={label} placeholder={label} />;
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
export const Basic: Story = {
|
|
@@ -99,13 +95,11 @@ export const LongErrors: Story = {
|
|
|
99
95
|
render: () => (
|
|
100
96
|
<YStack maxWidth={640}>
|
|
101
97
|
<FormGrid columns={2}>
|
|
102
|
-
<
|
|
98
|
+
<Input
|
|
103
99
|
id="fge-a"
|
|
104
100
|
label="With long error"
|
|
105
101
|
error="This validation message is extremely long and should wrap inside its own grid cell without breaking the neighbouring column alignment or overflowing the grid container"
|
|
106
|
-
|
|
107
|
-
<TamaguiInput id="fge-a" />
|
|
108
|
-
</FieldLayout>
|
|
102
|
+
/>
|
|
109
103
|
<GridField id="fge-b" label="Neighbour" />
|
|
110
104
|
</FormGrid>
|
|
111
105
|
</YStack>
|
|
@@ -40,6 +40,22 @@ describe("Input.Button", () => {
|
|
|
40
40
|
expect(btn.contains(ring)).toBe(true);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
it("a bare string child is a LABEL, not an icon type to createElement", () => {
|
|
44
|
+
const result = renderWithProviders(
|
|
45
|
+
<Input size="$4">
|
|
46
|
+
<Input.Box size="$4">
|
|
47
|
+
<Input.Button size="$4" aria-label="cap">
|
|
48
|
+
Scan
|
|
49
|
+
</Input.Button>
|
|
50
|
+
</Input.Box>
|
|
51
|
+
</Input>,
|
|
52
|
+
);
|
|
53
|
+
const btn = result.container.querySelector("[aria-label='cap']") as HTMLElement;
|
|
54
|
+
expect(btn).not.toBeNull();
|
|
55
|
+
expect(btn.textContent).toContain("Scan");
|
|
56
|
+
expect(result.container.querySelector("scan")).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
43
59
|
it("end-cap square is generated from the size recipe at every token", () => {
|
|
44
60
|
for (const token of SIZE_RECIPE_TOKENS) {
|
|
45
61
|
if (token === "$true") continue;
|
package/src/InputParts/index.tsx
CHANGED
|
@@ -1035,6 +1035,23 @@ const InputButtonImpl = InputButton.styleable<{ glyphRing?: boolean }>((buttonPr
|
|
|
1035
1035
|
size: knobProps.controlIcon.width,
|
|
1036
1036
|
color: paint as never,
|
|
1037
1037
|
});
|
|
1038
|
+
// A string/number child is a LABEL, not an icon. `getThemedIcon` treats an
|
|
1039
|
+
// un-elemented child as a component TYPE and `createElement`s it, so "Scan"
|
|
1040
|
+
// becomes an invented `<scan>` element that paints nothing (MPO-208); a bare
|
|
1041
|
+
// string under the glyphRing View is the same category error on native.
|
|
1042
|
+
const textChild =
|
|
1043
|
+
typeof children === "string" || typeof children === "number" ? (
|
|
1044
|
+
<Text
|
|
1045
|
+
{...knobProps.controlType}
|
|
1046
|
+
fontFamily={knobProps.body.fontFamily}
|
|
1047
|
+
fontWeight={knobProps.body.fontWeight}
|
|
1048
|
+
color={paint as never}
|
|
1049
|
+
numberOfLines={1}
|
|
1050
|
+
userSelect="none"
|
|
1051
|
+
>
|
|
1052
|
+
{children}
|
|
1053
|
+
</Text>
|
|
1054
|
+
) : null;
|
|
1038
1055
|
const decorativeRef = useCallback(
|
|
1039
1056
|
(node: TamaguiElement | null) => {
|
|
1040
1057
|
// Tamagui 2.7.6 Button stamps tabIndex=0 after consumer props.
|
|
@@ -1078,7 +1095,7 @@ const InputButtonImpl = InputButton.styleable<{ glyphRing?: boolean }>((buttonPr
|
|
|
1078
1095
|
alignItems="center"
|
|
1079
1096
|
justifyContent="center"
|
|
1080
1097
|
>
|
|
1081
|
-
{children}
|
|
1098
|
+
{textChild ?? children}
|
|
1082
1099
|
</View>
|
|
1083
1100
|
</InputGlyphButton>
|
|
1084
1101
|
);
|
|
@@ -1101,7 +1118,7 @@ const InputButtonImpl = InputButton.styleable<{ glyphRing?: boolean }>((buttonPr
|
|
|
1101
1118
|
data-end-cap={square}
|
|
1102
1119
|
{...props}
|
|
1103
1120
|
>
|
|
1104
|
-
{getThemedIcon(children)}
|
|
1121
|
+
{textChild ?? getThemedIcon(children)}
|
|
1105
1122
|
</InputButton>
|
|
1106
1123
|
);
|
|
1107
1124
|
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four form LAYOUT stories must mount house field chrome, not a raw
|
|
3
|
+
* tamagui primitive.
|
|
4
|
+
*
|
|
5
|
+
* FieldLayout, FieldGroup, Fieldset and FormGrid paint no radius of their own —
|
|
6
|
+
* they are stacks, a grid and a legend. Whatever radius a probe reads off one
|
|
7
|
+
* of their stories belongs to the CONTROL the story chose to mount. A raw
|
|
8
|
+
* `Input` from "tamagui" consumes no knobs, so it pins one radius at every
|
|
9
|
+
* stop and the axis reads dead against a component that never had a say.
|
|
10
|
+
*
|
|
11
|
+
* The measurement here is therefore the STORY's, not the component's: walk the
|
|
12
|
+
* borderRadius knob and assert the control inside each story tracks it.
|
|
13
|
+
*
|
|
14
|
+
* Under jsdom the providers inject no CSS and the radius atom keeps its token
|
|
15
|
+
* reference (`_btlr-t-radius-4`) rather than resolving to `9px`, so this file
|
|
16
|
+
* gates the token ladder. The pixel ladder (0/5/9/16/50) and the dark scheme
|
|
17
|
+
* are measured for real by scripts/knob-axis-probe.mjs against a browser.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { cleanup } from "@testing-library/react";
|
|
21
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
22
|
+
import { Preset, type BorderRadius } from "@multiplatform.one/theme";
|
|
23
|
+
import type { ReactElement } from "react";
|
|
24
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
25
|
+
|
|
26
|
+
import * as fieldGroupStories from "./FieldGroup.stories";
|
|
27
|
+
import * as fieldLayoutStories from "./FieldLayout.stories";
|
|
28
|
+
import * as fieldsetStories from "./Fieldset.stories";
|
|
29
|
+
import * as formGridStories from "./FormGrid.stories";
|
|
30
|
+
|
|
31
|
+
afterEach(cleanup);
|
|
32
|
+
|
|
33
|
+
/** Knob stop -> radius token index, the `$N` the resolver hands the frame. */
|
|
34
|
+
const ladder: Record<BorderRadius, string> = {
|
|
35
|
+
none: "0",
|
|
36
|
+
small: "2",
|
|
37
|
+
medium: "4",
|
|
38
|
+
large: "6",
|
|
39
|
+
full: "12",
|
|
40
|
+
};
|
|
41
|
+
const stops = Object.keys(ladder) as BorderRadius[];
|
|
42
|
+
|
|
43
|
+
type StoryLike = { render?: (args: Record<string, unknown>) => ReactElement };
|
|
44
|
+
|
|
45
|
+
/** Every radius token present in the subtree, deduped — the probe's reading. */
|
|
46
|
+
function radiusTokens(container: HTMLElement): string[] {
|
|
47
|
+
const found = new Set<string>();
|
|
48
|
+
for (const el of container.querySelectorAll<HTMLElement>("*")) {
|
|
49
|
+
for (const cls of String(el.className || "").split(/\s+/)) {
|
|
50
|
+
const match = /^_btlr-t-radius-(\d+)$/.exec(cls);
|
|
51
|
+
if (match) found.add(match[1]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...found].sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mountAt(story: StoryLike, meta: { args?: unknown }, stop: BorderRadius) {
|
|
58
|
+
if (!story.render) throw new Error("story has no render");
|
|
59
|
+
return renderWithProviders(
|
|
60
|
+
<Preset overrides={{ borderRadius: stop, elevation: "none" }}>
|
|
61
|
+
{story.render({ ...((meta.args ?? {}) as Record<string, unknown>) })}
|
|
62
|
+
</Preset>,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const family = [
|
|
67
|
+
{
|
|
68
|
+
name: "Forms/FieldLayout",
|
|
69
|
+
story: fieldLayoutStories.Basic as StoryLike,
|
|
70
|
+
meta: fieldLayoutStories.default,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "Forms/FieldGroup",
|
|
74
|
+
story: fieldGroupStories.Basic as StoryLike,
|
|
75
|
+
meta: fieldGroupStories.default,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "Forms/Fieldset",
|
|
79
|
+
story: fieldsetStories.Default as StoryLike,
|
|
80
|
+
meta: fieldsetStories.default,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "Forms/FormGrid",
|
|
84
|
+
story: formGridStories.Basic as StoryLike,
|
|
85
|
+
meta: formGridStories.default,
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
describe("form layout stories mount knob-consuming field chrome", () => {
|
|
90
|
+
for (const { name, story, meta } of family) {
|
|
91
|
+
describe(name, () => {
|
|
92
|
+
// A story that fails to render measures flat on every axis, which reads
|
|
93
|
+
// exactly like a dead knob. Prove there is a control before believing
|
|
94
|
+
// anything measured off it.
|
|
95
|
+
it("renders a text control at all", () => {
|
|
96
|
+
const { container } = mountAt(story, meta as { args?: unknown }, "medium");
|
|
97
|
+
expect(container.querySelectorAll("input").length).toBeGreaterThan(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
for (const stop of stops) {
|
|
101
|
+
it(`takes radius token $${ladder[stop]} at borderRadius:${stop}`, () => {
|
|
102
|
+
const { container } = mountAt(story, meta as { args?: unknown }, stop);
|
|
103
|
+
expect(radiusTokens(container)).toContain(ladder[stop]);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it("moves across all five stops instead of pinning one radius", () => {
|
|
108
|
+
const signatures = stops.map((stop) => {
|
|
109
|
+
const result = mountAt(story, meta as { args?: unknown }, stop);
|
|
110
|
+
const tokens = JSON.stringify(radiusTokens(result.container));
|
|
111
|
+
cleanup();
|
|
112
|
+
return tokens;
|
|
113
|
+
});
|
|
114
|
+
expect(new Set(signatures).size).toBe(stops.length);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
@@ -102,6 +102,8 @@ describe("Barcode", () => {
|
|
|
102
102
|
const button = result.container.querySelector("[aria-label='Scan barcode']");
|
|
103
103
|
expect(button).not.toBeNull();
|
|
104
104
|
expect(button?.textContent).toContain("Scan");
|
|
105
|
+
expect(button?.querySelector("scan")).toBeNull();
|
|
106
|
+
expect(result.container.querySelector("scan")).toBeNull();
|
|
105
107
|
});
|
|
106
108
|
|
|
107
109
|
it("renders readOnly — hides scan button", () => {
|
|
@@ -45,6 +45,14 @@ function getZone(container: Element): HTMLElement {
|
|
|
45
45
|
return zone as HTMLElement;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function borderWidthAtomPx(el: HTMLElement, prefix: string): number {
|
|
49
|
+
const atom = el.className.split(/\s+/).find((cls) => cls.startsWith(prefix));
|
|
50
|
+
// No atom means the side declares no width and falls through to the UA
|
|
51
|
+
// initial, which is exactly the defect — report it as such, not as 0.
|
|
52
|
+
if (!atom) return Number.NaN;
|
|
53
|
+
return Number.parseFloat(atom.slice(prefix.length));
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
async function pickFile(container: Element, file: File) {
|
|
49
57
|
const input = container.querySelector("input[type='file']") as HTMLInputElement | null;
|
|
50
58
|
expect(input).toBeTruthy();
|
|
@@ -317,6 +325,27 @@ describe("FileUpload bento-derived dropzone (MPO-57)", () => {
|
|
|
317
325
|
expect(zone.textContent).toContain("Choose File");
|
|
318
326
|
});
|
|
319
327
|
|
|
328
|
+
it("hangs the file list off ONE top hairline — bottom/left/right are 0, not the UA 3px initial (MPO-124)", async () => {
|
|
329
|
+
const { container } = renderWithHouseThemes(
|
|
330
|
+
<FileUpload label="Attachment" name="attachment" />,
|
|
331
|
+
);
|
|
332
|
+
await pickFile(container, new File(["hello"], "hello.txt", { type: "text/plain" }));
|
|
333
|
+
|
|
334
|
+
const zone = getZone(container);
|
|
335
|
+
const flist = zone.querySelector('[data-file-upload="files"]') as HTMLElement;
|
|
336
|
+
expect(flist).toBeTruthy();
|
|
337
|
+
|
|
338
|
+
// The frame declares `borderStyle: dashed` for all four sides. A side that
|
|
339
|
+
// declares no width therefore does not render nothing — it renders the CSS
|
|
340
|
+
// initial `medium` (3px) in `currentColor` ($color12), which is a heavy
|
|
341
|
+
// dashed BOX around the picked files instead of the one hairline the
|
|
342
|
+
// divider contract asks for.
|
|
343
|
+
expect(borderWidthAtomPx(flist, "_btw-")).toBe(1);
|
|
344
|
+
for (const prefix of ["_bbw-", "_blw-", "_brw-"] as const) {
|
|
345
|
+
expect(borderWidthAtomPx(flist, prefix)).toBe(0);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
320
349
|
it("keeps compact a space-only dial — CTA stays 44 and remove stays nested 32 (LC-76)", async () => {
|
|
321
350
|
const { container } = renderWithHouseThemes(
|
|
322
351
|
<FileUpload label="Attachment" name="attachment" compact />,
|
|
@@ -170,6 +170,12 @@ const DropOverlayFrame = styled(YStack, {
|
|
|
170
170
|
const UploadFileListFrame = styled(YStack, {
|
|
171
171
|
width: "100%",
|
|
172
172
|
borderTopWidth: 1,
|
|
173
|
+
// A side with a declared style and no declared width is NOT a side with no
|
|
174
|
+
// border: it takes the CSS initial `medium` (3px) in `currentColor`, which
|
|
175
|
+
// boxed the picked files in a heavy $color12 dash (MPO-124).
|
|
176
|
+
borderBottomWidth: 0,
|
|
177
|
+
borderLeftWidth: 0,
|
|
178
|
+
borderRightWidth: 0,
|
|
173
179
|
borderStyle: "dashed",
|
|
174
180
|
borderTopColor: formDropzoneColors.border,
|
|
175
181
|
});
|
|
@@ -213,13 +213,18 @@ export function filterDrawnIcons(
|
|
|
213
213
|
*/
|
|
214
214
|
const drawnIconInkToken = "color11";
|
|
215
215
|
|
|
216
|
-
function useDrawnInk(color?: string): string
|
|
216
|
+
function useDrawnInk(color?: string): string {
|
|
217
217
|
const theme = useTheme() as unknown as Record<string, { val?: unknown } | undefined>;
|
|
218
218
|
const raw = !color || color === "currentColor" ? `$${drawnIconInkToken}` : color;
|
|
219
219
|
const val = raw.startsWith("$") ? theme[raw.slice(1)]?.val : raw;
|
|
220
220
|
// An unknown consumer token falls back to the active theme's readable ink.
|
|
221
221
|
const fallback = theme[drawnIconInkToken]?.val ?? theme.color?.val;
|
|
222
|
-
|
|
222
|
+
if (typeof val === "string") return val;
|
|
223
|
+
if (typeof fallback === "string") return fallback;
|
|
224
|
+
// Outside a theme both lookups miss. Returning undefined is what MPO-72 was:
|
|
225
|
+
// react-native-svg takes it as nil and Core Graphics paints with whatever
|
|
226
|
+
// stroke was last in the context. A mid neutral is visible on both schemes.
|
|
227
|
+
return "#808080";
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
export function DrawnIcon({
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { RadioGroup } from "./index";
|
|
4
|
+
|
|
5
|
+
// MPO-283. Mock tamagui's isWeb so the native branch runs on the test host
|
|
6
|
+
// renderer, then read the React Native prop names back off the DOM. The state
|
|
7
|
+
// object itself is pinned in shared/nativeA11y.spec.ts — the host stringifies
|
|
8
|
+
// it — so what this file proves is that the component reaches the helper with
|
|
9
|
+
// the right role and the right name.
|
|
10
|
+
vi.mock("tamagui", async (importOriginal) => ({
|
|
11
|
+
...(await importOriginal<typeof import("tamagui")>()),
|
|
12
|
+
isWeb: false,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const phase = [
|
|
16
|
+
{ label: "CREDITS", value: "CREDITS" },
|
|
17
|
+
{ label: "DEBITS", value: "DEBITS" },
|
|
18
|
+
{ label: "DISCRETIONARY", value: "DISCRETIONARY", disabled: true },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const names = (container: HTMLElement) =>
|
|
22
|
+
Array.from(container.querySelectorAll("[accessibilityrole]")).map((el) => [
|
|
23
|
+
el.getAttribute("accessibilityrole"),
|
|
24
|
+
el.getAttribute("accessibilitylabel"),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
describe("native RadioGroup accessibility contract", () => {
|
|
28
|
+
it("makes every option a named control instead of bare StaticText", () => {
|
|
29
|
+
const { container } = renderWithProviders(
|
|
30
|
+
<RadioGroup label="Phase" options={phase} value="DEBITS" />,
|
|
31
|
+
);
|
|
32
|
+
expect(names(container)).toEqual([
|
|
33
|
+
["button", "CREDITS"],
|
|
34
|
+
["button", "DEBITS"],
|
|
35
|
+
["button", "DISCRETIONARY"],
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("prefixes a group accessibilityLabel onto each option, because iOS never reads a container", () => {
|
|
40
|
+
const { container } = renderWithProviders(
|
|
41
|
+
<RadioGroup accessibilityLabel="Phase" options={phase} value="DEBITS" />,
|
|
42
|
+
);
|
|
43
|
+
expect(names(container)).toContainEqual(["button", "Phase, DEBITS"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("takes aria-label as an alias, since tamagui never maps it onto native", () => {
|
|
47
|
+
const { container } = renderWithProviders(
|
|
48
|
+
<RadioGroup aria-label="Phase" options={phase} value="DEBITS" />,
|
|
49
|
+
);
|
|
50
|
+
expect(names(container)).toContainEqual(["button", "Phase, DEBITS"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("lets an option override its own name", () => {
|
|
54
|
+
const { container } = renderWithProviders(
|
|
55
|
+
<RadioGroup
|
|
56
|
+
options={[{ label: "▲", value: "up", accessibilityLabel: "Increasing" }]}
|
|
57
|
+
value="up"
|
|
58
|
+
/>,
|
|
59
|
+
);
|
|
60
|
+
expect(names(container)).toEqual([["button", "Increasing"]]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// The sibling Label is also told to stay out of the native tree so an option
|
|
64
|
+
// is not read twice. It cannot be asserted here — the web renderer drops the
|
|
65
|
+
// React Native accessibility props off Label entirely — so that one is
|
|
66
|
+
// measured on device instead.
|
|
67
|
+
});
|