Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .changeset/tokenizer-experimental-bottom-sheet.md

This file was deleted.

7 changes: 7 additions & 0 deletions .changeset/tokenizer-sheet-presentation-held-back.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: Tokenizer's touch sheet presentation is held back until its user experience is finished. The implementation stays in the package, reachable only through an internal entry reserved for Astryx's own packages; `TokenizerProps` offers no `experimental.presentation`, every Tokenizer renders the inline combobox, and nothing a consumer sees changes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Reachable only through an internal entry" and "every Tokenizer renders the inline combobox" hold only for callers that pass the attribute directly in TypeScript; a spread or a JavaScript caller still reaches the sheet through the public export (see the comment on Tokenizer.tsx).


@imdreamrunner
140 changes: 0 additions & 140 deletions apps/storybook/stories/Tokenizer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -798,143 +798,3 @@ export const CaretPlacement: Story = {
}
},
};
// A long, searchable dataset for the sheet presentation: enough entries to
// see the 50-row batches of progressive rendering.
const sheetTags: SearchableItem[] = Array.from({length: 1000}, (_, index) => {
const number = index + 1;
return {
id: `tag-${number}`,
label:
number === 1
? 'Design'
: number === 2
? 'Eng'
: `Tag ${String(number).padStart(2, '0')}`,
};
});

const sheetTagSource: SearchSource = {
search: (query: string) =>
sheetTags.filter(tag =>
tag.label.toLowerCase().includes(query.toLowerCase()),
),
bootstrap: () => sheetTags,
};

/**
* The experimental touch surface, forced on every device so it is reviewable
* on a laptop: `experimental.presentation="bottom-sheet"`. The trigger shows
* the selected tokens; tapping it opens one tall sheet with the search row on
* top, checkbox rows that keep their place while the sheet is open, a Create
* row for typed text, and a confirmed Clear all beside Done.
*/
export const ExperimentalBottomSheet: Story = {
render: args => {
const [value, setValue] = useState<SearchableItem[]>([
sheetTags[0],
sheetTags[1],
]);
return (
<Tokenizer
{...args}
searchSource={sheetTagSource}
value={value}
onChange={items => setValue(items)}
hasCreate
maxMenuItems={1000}
debounceMs={0}
experimental={{presentation: 'bottom-sheet'}}
/>
);
},
args: {
label: 'Tags',
placeholder: 'Add tags',
},
name: 'Experimental: bottom sheet',
};

/**
* `experimental.presentation="adaptive"`: the sheet on a compact
* coarse-pointer screen (narrow the viewport and emulate touch), otherwise the
* inline combobox — the same resolution Selector uses.
*/
export const ExperimentalAdaptive: Story = {
render: args => {
// Enough tokens to wrap the inline field's row on a phone, so the box
// the trigger keeps at the swap is the wrapped one (FR16, FR17).
const [value, setValue] = useState<SearchableItem[]>([
sheetTags[0],
sheetTags[1],
sheetTags[2],
sheetTags[3],
sheetTags[4],
]);
return (
<Tokenizer
{...args}
searchSource={sheetTagSource}
value={value}
onChange={items => setValue(items)}
maxMenuItems={1000}
debounceMs={0}
experimental={{presentation: 'adaptive'}}
/>
);
},
args: {
label: 'Tags',
placeholder: 'Add tags',
},
name: 'Experimental: adaptive',
};

/**
* Disabled and at-limit states of the sheet presentation: the disabled
* trigger stays focusable and does not open; at `maxEntries` the sheet's
* unchecked rows are inert while checked rows still toggle.
*/
export const ExperimentalBottomSheetStates: Story = {
render: args => {
const [value, setValue] = useState<SearchableItem[]>([
sheetTags[0],
sheetTags[1],
]);
return (
<div style={{display: 'grid', gap: 16}}>
<Tokenizer
{...args}
label="Disabled"
searchSource={sheetTagSource}
value={[sheetTags[0], sheetTags[1]]}
onChange={() => {}}
isDisabled
experimental={{presentation: 'bottom-sheet'}}
/>
<Tokenizer
{...args}
label="Disabled and empty"
searchSource={sheetTagSource}
value={[]}
onChange={() => {}}
isDisabled
experimental={{presentation: 'bottom-sheet'}}
/>
<Tokenizer
{...args}
label="At the limit"
searchSource={sheetTagSource}
value={value}
onChange={items => setValue(items)}
maxEntries={2}
debounceMs={0}
experimental={{presentation: 'bottom-sheet'}}
/>
</div>
);
},
args: {
placeholder: 'Add tags',
},
name: 'Experimental: bottom sheet states',
};
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,11 @@
"source": "./src/internal/touch.ts",
"types": "./dist/internal/touch.d.ts",
"default": "./dist/internal/touch.js"
},
"./internal/tokenizer-presentation": {
"source": "./src/internal/tokenizer-presentation.ts",
"types": "./dist/internal/tokenizer-presentation.d.ts",
"default": "./dist/internal/tokenizer-presentation.js"
}
},
"files": [
Expand Down
16 changes: 0 additions & 16 deletions packages/core/src/Tokenizer/Tokenizer.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -289,13 +289,6 @@ export const docs = {
type: 'CSSProperties',
description: 'Inline styles for the visual control box.',
},
{
name: 'experimental',
type: "{presentation?: 'popover' | 'bottom-sheet' | 'adaptive'}",
stability: 'experimental',
description:
"Experimental options: anything in this object may change or be removed in a patch release, and omitting it keeps the stable behavior. presentation (default 'popover') chooses how the field presents its choices. popover is the inline combobox with its anchored suggestion menu. bottom-sheet replaces it with a trigger showing the selected tokens that opens one tall modal sheet: a search row on top, full-width checkbox rows that keep their place while the sheet is open, a Create row with hasCreate, and a footer with a confirmed Clear all and Done. adaptive uses the bottom sheet on compact touch screens and the popover otherwise, resolved the same way Selector resolves it. In the sheet presentation the trigger is the control (ref, id, aria-* and data-* reach it; with experimental passed, ref also accepts a Ref<HTMLInputElement | HTMLButtonElement> — an input-only ref still type-checks against that wider type, but with adaptive or bottom-sheet it receives the trigger button at runtime, so type the ref for both elements; the exported TokenizerProps interface is unchanged), and renderItem, renderToken, endContent, hasClear, hasEntriesOnFocus, menuWidth, tokenOverflowBehavior and hasAutoFocus have no surface to act on; the field warns once in development when they are passed. For example: <Tokenizer label=\"Tags\" searchSource={tagSource} value={tags} onChange={setTags} hasCreate experimental={{presentation: 'adaptive'}} />.",
},
],
theming: {
targets: [
Expand Down Expand Up @@ -635,13 +628,6 @@ export const docsZh = {
type: 'CSSProperties',
description: '可视控件框的内联样式。',
},
{
name: 'experimental',
type: "{presentation?: 'popover' | 'bottom-sheet' | 'adaptive'}",
stability: 'experimental',
description:
"实验性选项:此对象中的任何内容都可能在补丁版本中更改或移除;省略它即保持稳定行为。presentation(默认 'popover')决定字段如何呈现选项:popover 为内联组合框及其锚定建议菜单;bottom-sheet 改为一个显示已选标记的触发按钮,点按后打开一个高的模态底部面板——顶部为搜索行,下方为整行复选框行(面板打开期间位置保持不变),hasCreate 时有「创建」行,底部为需确认的「全部清除」和「完成」;adaptive 在紧凑触摸屏上使用底部面板,其他情况使用 popover,解析方式与 Selector 相同。在底部面板呈现中,触发按钮即为控件(ref、id、aria-* 和 data-* 都作用于它;传入 experimental 后,ref 也接受 Ref<HTMLInputElement | HTMLButtonElement>——仅为输入框类型的 ref 在这个更宽的类型下仍能通过类型检查,但在 adaptive 或 bottom-sheet 下运行时收到的是触发按钮,因此请为两种元素声明 ref 的类型;导出的 TokenizerProps 接口不变);renderItem、renderToken、endContent、hasClear、hasEntriesOnFocus、menuWidth、tokenOverflowBehavior 和 hasAutoFocus 没有作用对象,传入时开发模式下会警告一次。例如:<Tokenizer label=\"标签\" searchSource={tagSource} value={tags} onChange={setTags} hasCreate experimental={{presentation: 'adaptive'}} />。",
},
],
theming: {
targets: [
Expand Down Expand Up @@ -839,7 +825,5 @@ export const docsDense = {
'Icon at input start, before tokens. Icon name, SVG component, or ReactNode.',
endContent: 'Content at input row end. For buttons, counts, controls.',
handleRef: 'Imperative handle for focus() and blur() control.',
experimental:
"Experimental; may change in a patch. presentation: 'popover' (default, inline combobox) | 'bottom-sheet' (trigger + one searchable modal sheet of checkbox rows) | 'adaptive' (sheet on compact touch, else popover).",
},
};
30 changes: 21 additions & 9 deletions packages/core/src/Tokenizer/Tokenizer.props-type.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
* @input Uses vitest, TokenizerProps and Tokenizer
* @output Type-level proof that the exported `TokenizerProps` stays an
* interface callers can extend and `Omit` (its `ref` typed for the combobox
* input as released), and that only the experimental opt-in admits a `ref`
* that also accepts the sheet presentation's trigger button
* input as released), that the public component names no `experimental`
* presentation, and that only the internal `TokenizerWithPresentation`
* admits the presentation options and a `ref` that also accepts the sheet
* presentation's trigger button
* @position Core type tests; checked by `tsc`, with `@ts-expect-error`
* marking the shapes that must keep failing
*/
import {createRef, type ComponentProps} from 'react';
import {describe, it, expect} from 'vitest';
import type {SearchableItem} from '../Typeahead/types';
import {Tokenizer, type TokenizerProps} from './Tokenizer';
import {TokenizerWithPresentation} from '../internal/tokenizer-presentation';

type Item = SearchableItem;
const source = {search: () => [], bootstrap: () => []};
Expand Down Expand Up @@ -62,13 +65,22 @@ export function compiles() {
onChange={() => {}}
ref={inputRef}
/>
{/* The public props name no presentation. */}
<TagsField
searchSource={source}
value={[]}
onChange={() => {}}
ref={inputRef}
// @ts-expect-error -- `experimental` is not on TokenizerProps
experimental={{presentation: 'adaptive'}}
/>
<Tokenizer
label="Tags"
searchSource={source}
value={[]}
onChange={() => {}}
// @ts-expect-error -- nor on the public component
experimental={{presentation: 'bottom-sheet'}}
/>
{/* The stable shape: ref is the combobox input's. */}
<Tokenizer
label="Tags"
Expand All @@ -77,38 +89,38 @@ export function compiles() {
onChange={() => {}}
ref={inputRef}
/>
{/* The experimental opt-in admits the wider ref. */}
<Tokenizer
{/* The internal signature admits the presentation and the wider ref. */}
<TokenizerWithPresentation
label="Tags"
searchSource={source}
value={[]}
onChange={() => {}}
ref={wideRef}
experimental={{presentation: 'adaptive'}}
/>
{/* An input-typed ref is still fine with the opt-in. */}
<Tokenizer
{/* An input-typed ref is still fine there. */}
<TokenizerWithPresentation
label="Tags"
searchSource={source}
value={[]}
onChange={() => {}}
ref={inputRef}
experimental={{}}
/>
{/* @ts-expect-error -- without experimental, ref keeps the released input-only type */}
<Tokenizer
label="Tags"
searchSource={source}
value={[]}
onChange={() => {}}
// @ts-expect-error -- the public ref keeps the released input-only type
ref={wideRef}
/>
</>
);
}

describe('Tokenizer props type', () => {
it('keeps TokenizerProps an interface that extends and Omit-wraps (type-checked above)', () => {
it('keeps TokenizerProps an interface that extends and Omit-wraps, with no presentation option on the public component (type-checked above)', () => {
expect(typeof compiles).toBe('function');
});
});
Loading
Loading