diff --git a/src/uix/eidos/components/password-field/password-field.css b/src/uix/eidos/components/password-field/password-field.css index 91444d483..1fccd0986 100644 --- a/src/uix/eidos/components/password-field/password-field.css +++ b/src/uix/eidos/components/password-field/password-field.css @@ -5,6 +5,10 @@ * Mirrors search-field's chrome (border, radius, focus ring, padding, * disabled opacity) so every text input in the system reads the same * — only the affordances inside the control differ. + * + * Layout: CSS grid keeps the inner controls (input, caps indicator, + * visibility trigger) on the first row and the StrengthMeter on a + * second row that sits OUTSIDE the bordered chrome. */ [data-password-field] { @@ -18,10 +22,10 @@ --_password-field-accent-track: var(--color-primary-track); --_password-field-accent-text: var(--color-primary-text); - display: inline-flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + column-gap: var(--_password-field-gap); align-items: center; - gap: var(--_password-field-gap); inline-size: 100%; min-inline-size: 0; block-size: var(--_password-field-height); @@ -39,12 +43,57 @@ box-shadow var(--password-field-transition-duration) var(--password-field-transition-ease); } -/* When the StrengthMeter is composed, it claims a second row and the - * height auto-grows; otherwise the single-row chrome rules. */ +/* When the StrengthMeter is composed, the root becomes a vertical stack: + * the bordered control row on top, the meter as a separate sibling below. + * Border + padding move to a synthetic ::before pseudo-row… but since the + * meter is a real child, the cleanest move is to lift the chrome to the + * inner cells. */ [data-password-field]:has([data-password-field-strength-meter]) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-rows: var(--_password-field-height) auto; + row-gap: var(--space-2); block-size: auto; - min-block-size: var(--_password-field-height); - padding-block: var(--password-field-gap-sm); + border: 0; + background: transparent; + padding: 0; + box-shadow: none; +} + +/* When the meter is present, the first row recreates the bordered chrome + * via a pseudo-element that spans all 3 columns. The inner controls keep + * their normal flow because the pseudo sits behind them (z-index 0). */ +[data-password-field]:has([data-password-field-strength-meter])::before { + content: ''; + grid-column: 1 / -1; + grid-row: 1 / 2; + border: var(--password-field-border-width) solid var(--password-field-border); + border-radius: var(--password-field-radius); + background: var(--password-field-bg); + transition: + background var(--password-field-transition-duration) var(--password-field-transition-ease), + border-color var(--password-field-transition-duration) var(--password-field-transition-ease), + box-shadow var(--password-field-transition-duration) var(--password-field-transition-ease); + pointer-events: none; +} + +[data-password-field]:has([data-password-field-strength-meter]) [data-password-field-input], +[data-password-field]:has([data-password-field-strength-meter]) [data-password-field-visibility-trigger], +[data-password-field]:has([data-password-field-strength-meter]) [data-password-field-caps-lock-indicator] { + grid-row: 1 / 2; + padding-inline-start: 0; + z-index: 1; +} +[data-password-field]:has([data-password-field-strength-meter]) [data-password-field-input] { + grid-column: 1 / 2; + padding-inline-start: var(--_password-field-px); +} +[data-password-field]:has([data-password-field-strength-meter]) [data-password-field-caps-lock-indicator] { + grid-column: 2 / 3; +} +[data-password-field]:has([data-password-field-strength-meter]) [data-password-field-visibility-trigger] { + grid-column: 3 / 4; + margin-inline-end: calc(var(--_password-field-px) - var(--_password-field-gap)); } /* ── Color accents ─────────────────────────────────────────────────── */ @@ -125,12 +174,19 @@ [data-password-field][data-variant='outline'] { background: transparent; } +[data-password-field][data-variant='outline']:has([data-password-field-strength-meter])::before { + background: transparent; +} [data-password-field][data-variant='ghost'] { border-color: transparent; background: transparent; padding-inline: 0; } +[data-password-field][data-variant='ghost']:has([data-password-field-strength-meter])::before { + border-color: transparent; + background: transparent; +} /* ── State chrome ──────────────────────────────────────────────────── */ @@ -138,10 +194,17 @@ border-color: var(--password-field-border-focus); box-shadow: var(--password-field-focus-shadow); } +[data-password-field][data-focused]:has([data-password-field-strength-meter])::before { + border-color: var(--password-field-border-focus); + box-shadow: var(--password-field-focus-shadow); +} [data-password-field][data-invalid] { border-color: var(--password-field-border-invalid); } +[data-password-field][data-invalid]:has([data-password-field-strength-meter])::before { + border-color: var(--password-field-border-invalid); +} [data-password-field][data-disabled] { opacity: var(--password-field-disabled-opacity); @@ -150,8 +213,8 @@ /* ── Input ─────────────────────────────────────────────────────────── */ [data-password-field-input] { - flex: 1 1 auto; min-inline-size: 0; + inline-size: 100%; border: 0; background: transparent; color: inherit; @@ -173,13 +236,11 @@ /* ── VisibilityTrigger ─────────────────────────────────────────────── */ [data-password-field-visibility-trigger] { - flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; inline-size: var(--_password-field-trigger-size); block-size: var(--_password-field-trigger-size); - margin-block: auto; padding: 0; border: var(--password-field-trigger-border-width) solid var(--password-field-trigger-border); border-radius: var(--password-field-trigger-radius); @@ -216,29 +277,25 @@ /* ── CapsLockIndicator ─────────────────────────────────────────────── */ +/* Hidden by default. Only visible when caps lock is on. */ [data-password-field-caps-lock-indicator] { - flex: 0 0 auto; - display: inline-flex; + display: none; align-items: center; gap: 0.25em; - margin-block: auto; padding-inline: 0.4em; padding-block: 0.2em; border-radius: var(--radius-sm); font-size: var(--_password-field-caps-font-size); font-weight: var(--font-weight-medium, 500); - color: transparent; - background: transparent; - transition: - color var(--password-field-transition-duration) var(--password-field-transition-ease), - background var(--password-field-transition-duration) var(--password-field-transition-ease); + color: var(--password-field-caps-color); + background: var(--password-field-caps-bg); pointer-events: none; user-select: none; + white-space: nowrap; } [data-password-field-caps-lock-indicator][data-active] { - color: var(--password-field-caps-color); - background: var(--password-field-caps-bg); + display: inline-flex; } [data-password-field-caps-lock-glyph] { @@ -249,11 +306,12 @@ /* ── StrengthMeter (composable; layout below the control row) ─────── */ [data-password-field-strength-meter] { - flex: 1 1 100%; display: flex; flex-direction: column; gap: 0.25em; - margin-top: var(--space-1); + grid-column: 1 / -1; + grid-row: 2 / 3; + padding-inline: var(--_password-field-px); } [data-password-field-strength-meter-track] { diff --git a/src/uix/eidos/components/words/index.ts b/src/uix/eidos/components/words/index.ts index c43d94b64..59315da97 100644 --- a/src/uix/eidos/components/words/index.ts +++ b/src/uix/eidos/components/words/index.ts @@ -38,7 +38,11 @@ export type { WordsProps, WordsSize, WordsStatusProps as StatusProps, + WordsToolbarCommandConfig, + WordsToolbarConfig, + WordsToolbarGroupConfig, WordsToolbarGroupProps as ToolbarGroupProps, WordsToolbarProps as ToolbarProps, + WordsToolbarPreset, WordsVariant } from './types'; diff --git a/src/uix/eidos/components/words/types.ts b/src/uix/eidos/components/words/types.ts index 6db0bdb18..1a7016f2a 100644 --- a/src/uix/eidos/components/words/types.ts +++ b/src/uix/eidos/components/words/types.ts @@ -8,11 +8,31 @@ import type { ToolbarGroupProps, ToolbarProps } from '$soma/components/words'; +import type { + WordsCommandName, + WordsHeadingLevel, + WordsListKind, + WordsToolbarOrientation +} from '$soma/components/words'; import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'; export type WordsSize = Extract; export type WordsVariant = ControlVariant; export type WordsColor = ColorRole; +export type WordsToolbarPreset = 'minimal' | 'formatting' | 'full'; +export type WordsToolbarCommandConfig = + | WordsCommandName + | { + command: WordsCommandName; + label?: string; + level?: WordsHeadingLevel; + listKind?: WordsListKind; + }; +export type WordsToolbarGroupConfig = readonly WordsToolbarCommandConfig[]; +export type WordsToolbarConfig = + | boolean + | WordsToolbarPreset + | readonly WordsToolbarGroupConfig[]; export type WordsProps = ProviderProps & { /** Composite editor density. @default 'md' */ @@ -21,6 +41,10 @@ export type WordsProps = ProviderProps & { variant?: WordsVariant; /** Accent palette for focus, active commands and selections. @default 'primary' */ color?: WordsColor; + /** Optional default toolbar renderer. Manual compound children still win for custom UI. */ + toolbar?: WordsToolbarConfig; + /** Orientation for the toolbar rendered from the `toolbar` prop. @default 'horizontal' */ + toolbarOrientation?: WordsToolbarOrientation; }; export type WordsContentProps = ContentProps; diff --git a/src/uix/eidos/components/words/words.svelte b/src/uix/eidos/components/words/words.svelte index 3014a510e..5804414b1 100644 --- a/src/uix/eidos/components/words/words.svelte +++ b/src/uix/eidos/components/words/words.svelte @@ -1,12 +1,38 @@ {#snippet children(snippetProps)} + {#if toolbarGroups.length} + + {#each toolbarGroups as group} + + {#each group as item} + {@const command = normalizeCommand(item)} + + {#if command.command === 'undo'} + + {:else if command.command === 'redo'} + + {:else if command.command === 'bold'} + + {:else if command.command === 'italic'} + + {:else if command.command === 'underline'} + + {:else if command.command === 'strike'} + + {:else if command.command === 'code'} + + {:else if command.command === 'paragraph'} + + {:else if command.command === 'heading'} + + {:else if command.command === 'quote'} + + {:else if command.command === 'unordered-list'} + + {:else if command.command === 'ordered-list'} + + {:else if command.command === 'check-list'} + + {:else if command.command === 'commit'} + + {:else} + + {/if} + + {/each} + + {/each} + + {/if} {@render bodyContent?.(snippetProps)} {/snippet} diff --git a/src/uix/soma/components/words/components/words-command-button.svelte b/src/uix/soma/components/words/components/words-command-button.svelte index d8a984625..f7416cd72 100644 --- a/src/uix/soma/components/words/components/words-command-button.svelte +++ b/src/uix/soma/components/words/components/words-command-button.svelte @@ -31,10 +31,15 @@ const mergedProps = $derived(mergeProps(restProps, state.props)); const buttonProps = $derived.by(() => { - const { onclick, ...props } = mergedProps; + const { onclick, onpointerdown, ...props } = mergedProps; return props; }); + function callPointerDown(event: PointerEvent) { + const handler = (mergedProps as Record).onpointerdown; + if (typeof handler === 'function') handler(event); + } + function callClick(event: MouseEvent) { const handler = (mergedProps as Record).onclick; if (typeof handler === 'function') handler(event); @@ -44,7 +49,7 @@ {#if child} {@render child({ props: mergedProps })} {:else} -