diff --git a/.gitignore b/.gitignore index 12acd87e..8c058692 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ build-metadata.json datasets.json +.playwright-mcp + scratchpad .vscode @@ -131,4 +133,4 @@ tests/*.dylib tests/*.so tests/*.dll -.idea \ No newline at end of file +.idea diff --git a/datasette/app.py b/datasette/app.py index 9979b6c5..545a65c8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -66,6 +66,7 @@ from .views.index import IndexView from .views.special import ( JsonDataView, PatternPortfolioView, + AutocompleteDebugView, AuthTokenView, ApiExplorerView, CreateTokenView, @@ -82,10 +83,12 @@ from .views.special import ( TableSchemaView, ) from .views.table import ( + TableAutocompleteView, TableInsertView, TableUpsertView, TableSetColumnTypeView, TableDropView, + TableFragmentView, table_view, ) from .views.row import RowView, RowDeleteView, RowUpdateView @@ -2016,6 +2019,11 @@ class Datasette: other_table = fk["other_table"] other_column = fk["other_column"] + if other_column is None: + other_pks = await db.primary_keys(other_table) + if len(other_pks) != 1: + return {} + other_column = other_pks[0] visible, _ = await self.check_visibility( actor, action="view-table", @@ -2312,6 +2320,8 @@ class Datasette: and "ds_actor" in request.cookies and request.actor, "app_css_hash": self.app_css_hash(), + "edit_tools_js_hash": self.static_hash("edit-tools.js"), + "table_js_hash": self.static_hash("table.js"), "zip": zip, "body_scripts": body_scripts, "format_bytes": format_bytes, @@ -2536,6 +2546,10 @@ class Datasette: wrap_view(PatternPortfolioView, self), r"/-/patterns$", ) + add_route( + AutocompleteDebugView.as_view(self), + r"/-/debug/autocomplete$", + ) add_route( wrap_view(database_download, self), r"/(?P[^\/\.]+)\.db$", @@ -2613,6 +2627,14 @@ class Datasette: TableSetColumnTypeView.as_view(self), r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/set-column-type$", ) + add_route( + TableFragmentView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/fragment$", + ) + add_route( + TableAutocompleteView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/autocomplete$", + ) add_route( TableDropView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/drop$", diff --git a/datasette/column_types.py b/datasette/column_types.py index 7320e1d6..11a14ec0 100644 --- a/datasette/column_types.py +++ b/datasette/column_types.py @@ -6,19 +6,17 @@ class SQLiteType(Enum): INTEGER = "INTEGER" REAL = "REAL" BLOB = "BLOB" - NULL = "NULL" + NUMERIC = "NUMERIC" @classmethod - def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None": + def from_declared_type(cls, declared_type: str | None) -> "SQLiteType": if declared_type is None: - return cls.NULL + return cls.BLOB normalized = declared_type.strip().upper() if not normalized: - return cls.NULL + return cls.BLOB - if normalized == cls.NULL.value: - return cls.NULL if "INT" in normalized: return cls.INTEGER if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")): @@ -31,7 +29,7 @@ class SQLiteType(Enum): ): return cls.REAL - return None + return cls.NUMERIC class ColumnType: diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py index 24493994..f90a733e 100644 --- a/datasette/default_column_types.py +++ b/datasette/default_column_types.py @@ -76,6 +76,12 @@ class JsonColumnType(ColumnType): return None +class TextareaColumnType(ColumnType): + name = "textarea" + description = "Multiline text" + sqlite_types = (SQLiteType.TEXT,) + + @hookimpl def register_column_types(datasette): - return [UrlColumnType, EmailColumnType, JsonColumnType] + return [UrlColumnType, EmailColumnType, JsonColumnType, TextareaColumnType] diff --git a/datasette/default_debug_menu.py b/datasette/default_debug_menu.py index 6127b2a6..8ea3c287 100644 --- a/datasette/default_debug_menu.py +++ b/datasette/default_debug_menu.py @@ -37,6 +37,11 @@ DEBUG_MENU_ITEMS = ( "Debug allow rules", "Explore how allow blocks match actors against permission rules.", ), + ( + "/-/debug/autocomplete", + "Debug autocomplete", + "Try out table autocomplete against a detected label column.", + ), ( "/-/threads", "Debug threads", diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index dcd502af..7c56f882 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -159,32 +159,32 @@ def jump_items_sql(datasette, actor, request): @hookspec def row_actions(datasette, actor, request, database, table, row): - """Links for the row actions menu""" + """Items for the row actions menu""" @hookspec def table_actions(datasette, actor, database, table, request): - """Links for the table actions menu""" + """Items for the table actions menu""" @hookspec def view_actions(datasette, actor, database, view, request): - """Links for the view actions menu""" + """Items for the view actions menu""" @hookspec def query_actions(datasette, actor, database, query_name, request, sql, params): - """Links for the query and stored query actions menu""" + """Items for the query and stored query actions menu""" @hookspec def database_actions(datasette, actor, database, request): - """Links for the database actions menu""" + """Items for the database actions menu""" @hookspec def homepage_actions(datasette, actor, request): - """Links for the homepage actions menu""" + """Items for the homepage actions menu""" @hookspec diff --git a/datasette/static/app.css b/datasette/static/app.css index 6d675d9f..5fe4502d 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -792,9 +792,9 @@ p.zero-results { dialog.mobile-column-actions-dialog { --ink: #0f0f0f; - --paper: #f5f3ef; + --paper: #eef6ff; --muted: #6b6b6b; - --rule: #e2dfd8; + --rule: #d8e6f5; --accent: #1a56db; --card: #ffffff; border: none; @@ -1020,9 +1020,9 @@ dialog.mobile-column-actions-dialog::backdrop { dialog.set-column-type-dialog { --ink: #0f0f0f; - --paper: #f5f3ef; + --paper: #eef6ff; --muted: #6b6b6b; - --rule: #e2dfd8; + --rule: #d8e6f5; --accent: #1a56db; --card: #ffffff; border: none; @@ -1109,7 +1109,7 @@ dialog.set-column-type-dialog::backdrop { padding: 14px 16px; border: 1px solid var(--rule); border-radius: 8px; - background: #fcfbf9; + background: #fbfdff; cursor: pointer; } @@ -1192,6 +1192,607 @@ dialog.set-column-type-dialog::backdrop { cursor: wait; } +.row-mutation-status { + margin: 0 0 0.75rem; + padding: 8px 10px; + border-left: 4px solid #54AC8E; + background: rgba(103,201,141,0.12); + color: #222; +} + +.row-mutation-status[hidden] { + display: none; +} + +.row-mutation-status-error { + border-left-color: #D0021B; + background: rgba(208,2,27,0.12); +} + +.table-row-toolbar { + margin: 0 0 0.75rem; +} + +button.table-insert-row { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +button.table-insert-row svg { + display: block; + flex-shrink: 0; +} + +dialog.row-delete-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(440px, calc(100vw - 32px)); + max-width: 95vw; + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.row-delete-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.row-delete-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.row-delete-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.row-delete-dialog .modal-title { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.row-delete-message, +.row-delete-error { + margin: 0; + padding: 16px 24px 0; +} + +.row-delete-message { + color: var(--ink); + font-size: 0.95rem; +} + +.row-delete-id { + display: inline; + padding: 2px 5px; + border: 1px solid var(--rule); + border-radius: 4px; + background: var(--paper); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.92em; + overflow-wrap: anywhere; +} + +.row-delete-error { + color: #b91c1c; + font-size: 0.9rem; +} + +.row-delete-dialog .modal-footer { + padding: 18px 20px 14px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); + margin-top: 18px; +} + +.row-delete-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.row-delete-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.row-delete-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.row-delete-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.row-delete-dialog .btn-primary:hover { + background: #1949b8; +} + +.row-delete-dialog .btn:disabled { + opacity: 0.65; + cursor: wait; +} + +dialog.row-edit-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(720px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(780px, calc(100vh - 32px)); + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.row-edit-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.row-edit-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.row-edit-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.row-edit-dialog .modal-title { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.row-edit-dialog .modal-title .row-dialog-action, +.row-delete-dialog .modal-title .row-dialog-action { + flex: 0 0 auto; + white-space: nowrap; +} + +.row-edit-dialog .modal-title code, +.row-delete-dialog .modal-title code { + display: inline; + flex: 0 0 auto; + padding: 2px 5px; + border: 1px solid var(--rule); + border-radius: 4px; + background: var(--paper); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.92em; + overflow-wrap: anywhere; +} + +.row-edit-dialog .modal-title .row-dialog-label, +.row-delete-dialog .modal-title .row-dialog-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-edit-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.row-edit-summary, +.row-edit-loading, +.row-edit-error { + margin: 0; + padding: 12px 24px 0; +} + +.row-edit-summary, +.row-edit-loading { + color: var(--muted); + font-size: 0.9rem; +} + +.row-edit-error { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 12px 24px 0; + padding: 10px 12px; +} + +.row-edit-error:focus { + outline: 3px solid rgba(185, 28, 28, 0.18); + outline-offset: 2px; +} + +.row-edit-fields { + display: grid; + gap: 14px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.row-edit-field { + display: grid; + grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.row-edit-label { + padding-top: 8px; + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + overflow-wrap: anywhere; +} + +.row-edit-control-wrap { + display: grid; + gap: 5px; +} + +.row-edit-input { + box-sizing: border-box; + width: 100%; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 8px 10px; + color: var(--ink); + background: #fff; + font: inherit; +} + +textarea.row-edit-input { + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.row-edit-input:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.row-edit-input[aria-invalid="true"] { + border-color: #b42318; + background: #fff8f7; +} + +.row-edit-input[aria-invalid="true"]:focus { + border-color: #b42318; + outline-color: rgba(180, 35, 24, 0.16); +} + +.row-edit-input[readonly] { + color: var(--muted); + background: var(--paper); +} + +.row-edit-default { + display: grid; + grid-template-columns: minmax(0, 1fr) 7.25rem; + align-items: center; + gap: 8px; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 7px 8px 7px 10px; + background: var(--paper); + color: var(--ink); +} + +.row-edit-default[hidden], +.row-edit-custom-value[hidden] { + display: none; +} + +.row-edit-default-text { + min-width: 0; + overflow-wrap: anywhere; +} + +.row-edit-default-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.row-edit-custom-value { + display: grid; + grid-template-columns: minmax(0, 1fr) 7.25rem; + gap: 8px; + align-items: center; + min-height: 45px; + padding-right: 8px; +} + +.row-edit-default-button { + appearance: none; + border: 1px solid var(--rule); + border-radius: 4px; + background: #fff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + line-height: 1.2; + padding: 6px 8px; + white-space: nowrap; + width: 100%; + align-self: center; +} + +.row-edit-default-button:hover, +.row-edit-default-button:focus { + background: #f8fafc; +} + +.row-edit-default-button:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.row-edit-field-meta { + color: var(--muted); + font-size: 0.78rem; +} + +.row-edit-field-validation-error { + color: #b42318; + display: block; + margin-top: 2px; +} + +.row-edit-field-validation-error[hidden] { + display: none; +} + +.row-edit-field-meta-autocomplete { + line-height: 1.2; + min-height: 1.2em; +} + +.row-edit-fk-pk { + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.row-edit-fk-link { + overflow-wrap: anywhere; +} + +.row-edit-empty { + color: var(--muted); + font-size: 0.9rem; + margin: 0; +} + +datasette-autocomplete { + display: block; + position: relative; + max-width: 38rem; +} + +datasette-autocomplete input[type="text"], +.debug-autocomplete-form input[type="text"] { + box-sizing: border-box; + width: 100%; + max-width: 38rem; +} + +.datasette-autocomplete-list { + background: #fff; + border: 1px solid var(--rule); + border-radius: 5px; + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14); + box-sizing: border-box; + left: 0; + max-height: 16rem; + overflow-y: auto; + position: fixed; + right: auto; + top: auto; + z-index: 10000; +} + +.datasette-autocomplete-list[hidden] { + display: none; +} + +.datasette-autocomplete-option { + cursor: pointer; + padding: 7px 9px; +} + +.datasette-autocomplete-option:hover, +.datasette-autocomplete-option[aria-selected="true"] { + background: var(--paper); +} + +.datasette-autocomplete-option[aria-selected="true"] { + background: var(--paper); + font-weight: 600; +} + +.datasette-autocomplete-status { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.debug-autocomplete-demo { + margin: 1rem 0; +} + +.debug-autocomplete-selected { + max-width: 46rem; +} + +.row-edit-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.row-edit-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.row-edit-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.row-edit-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.row-edit-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.row-edit-dialog .btn-primary:hover { + background: #1949b8; +} + +.row-edit-dialog .btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.row-link-with-actions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.row-inline-actions { + display: inline-flex; + gap: 0.2rem; + align-items: center; +} + +.row-inline-action { + appearance: none; + border: 1px solid rgba(74, 85, 104, 0.24); + background: transparent; + color: #4a5568; + border-radius: 4px; + cursor: pointer; + display: inline-grid; + place-items: center; + min-height: 24px; + min-width: 24px; + padding: 2px; + position: relative; +} + +.row-inline-action:hover, +.row-inline-action:focus { + background: rgba(74, 85, 104, 0.07); +} + +.row-inline-action:focus { + outline: 3px solid #b3d4ff; + outline-offset: 1px; +} + +.row-inline-action-icon { + display: block; + height: 13px; + width: 13px; +} + @media (max-width: 640px) { dialog.mobile-column-actions-dialog { width: 95vw; @@ -1239,6 +1840,68 @@ dialog.set-column-type-dialog::backdrop { padding-left: 18px; padding-right: 18px; } + + dialog.row-delete-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .row-delete-dialog .modal-header, + .row-delete-message, + .row-delete-error { + padding-left: 18px; + padding-right: 18px; + } + + .row-delete-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + + dialog.row-edit-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .row-edit-dialog .modal-header, + .row-edit-summary, + .row-edit-loading, + .row-edit-fields { + padding-left: 18px; + padding-right: 18px; + } + + .row-edit-error { + margin-left: 18px; + margin-right: 18px; + } + + .row-edit-field { + grid-template-columns: 1fr; + gap: 5px; + } + + .row-edit-label { + padding-top: 0; + } + + .row-edit-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + + .row-inline-action { + min-height: 30px; + min-width: 30px; + padding: 4px; + } + + .row-inline-action-icon { + height: 14px; + width: 14px; + } } @media only screen and (max-width: 576px) { @@ -1293,6 +1956,10 @@ dialog.set-column-type-dialog::backdrop { font-size: 0.8em; } + .row-inline-actions { + margin-bottom: 0.35rem; + } + .select-wrapper { width: 100px; } @@ -1303,6 +1970,7 @@ dialog.set-column-type-dialog::backdrop { width: 140px; } button.choose-columns-mobile, + button.table-insert-row, button.column-actions-mobile { display: inline-flex; align-items: center; @@ -1339,6 +2007,15 @@ dialog.set-column-type-dialog::backdrop { button.choose-columns-mobile { margin-right: 0.5rem; } + + .table-row-toolbar { + margin-bottom: 0.75rem; + } + + button.table-insert-row { + width: 100%; + margin-bottom: 0; + } } svg.dropdown-menu-icon { @@ -1384,18 +2061,32 @@ svg.dropdown-menu-icon { .dropdown-menu a:link, .dropdown-menu a:visited, .dropdown-menu a:hover, -.dropdown-menu a:focus -.dropdown-menu a:active { +.dropdown-menu a:focus, +.dropdown-menu a:active, +.dropdown-menu button.action-menu-button { text-decoration: none; display: block; padding: 4px 8px 2px 8px; color: #222; white-space: nowrap; } -.dropdown-menu a:hover { +.dropdown-menu button.action-menu-button { + appearance: none; + background: none; + border: none; + box-sizing: border-box; + cursor: pointer; + font: inherit; + text-align: left; + width: 100%; +} +.dropdown-menu a:hover, +.dropdown-menu button.action-menu-button:hover, +.dropdown-menu button.action-menu-button:focus { background-color: #eee; } .dropdown-menu .dropdown-description { + display: block; margin: 0; color: #666; font-size: 0.8em; diff --git a/datasette/static/autocomplete.js b/datasette/static/autocomplete.js new file mode 100644 index 00000000..c615000e --- /dev/null +++ b/datasette/static/autocomplete.js @@ -0,0 +1,344 @@ +(function () { + function autocompleteValueFromRow(row) { + var pks = (row && row.pks) || {}; + var keys = Object.keys(pks); + if (!keys.length) { + return ""; + } + if (keys.length === 1) { + return String(pks[keys[0]]); + } + return keys + .map(function (key) { + return key + "=" + pks[key]; + }) + .join(", "); + } + + function autocompleteLabelFromRow(row) { + var value = autocompleteValueFromRow(row); + if (row.label && String(row.label) !== value) { + return row.label + " (" + value + ")"; + } + return value; + } + + if (!window.customElements || customElements.get("datasette-autocomplete")) { + return; + } + + class DatasetteAutocomplete extends HTMLElement { + constructor() { + super(); + this.input = null; + this.listbox = null; + this.status = null; + this.results = []; + this.activeIndex = -1; + this.fetchId = 0; + this.searchTimer = null; + this.boundInput = this.handleInput.bind(this); + this.boundKeydown = this.handleKeydown.bind(this); + this.boundBlur = this.handleBlur.bind(this); + this.boundFocus = this.handleFocus.bind(this); + this.boundPositionListbox = this.positionListbox.bind(this); + } + + connectedCallback() { + if (this.input) { + return; + } + this.input = this.querySelector("input"); + if (!this.input) { + return; + } + + var inputId = + this.input.id || + "datasette-autocomplete-" + Math.random().toString(36).slice(2); + this.input.id = inputId; + var listboxId = inputId + "-listbox"; + var statusId = inputId + "-status"; + + this.classList.add("datasette-autocomplete"); + this.input.setAttribute("role", "combobox"); + this.input.setAttribute("aria-autocomplete", "list"); + this.input.setAttribute("aria-expanded", "false"); + this.input.setAttribute("aria-controls", listboxId); + this.input.setAttribute("autocomplete", "off"); + + this.listbox = document.createElement("div"); + this.listbox.className = "datasette-autocomplete-list"; + this.listbox.id = listboxId; + this.listbox.setAttribute("role", "listbox"); + this.listbox.hidden = true; + + this.status = document.createElement("span"); + this.status.className = "datasette-autocomplete-status"; + this.status.id = statusId; + this.status.setAttribute("role", "status"); + this.status.setAttribute("aria-live", "polite"); + + this.input.setAttribute( + "aria-describedby", + [this.input.getAttribute("aria-describedby"), statusId] + .filter(Boolean) + .join(" "), + ); + + this.appendChild(this.listbox); + this.appendChild(this.status); + + this.input.addEventListener("input", this.boundInput); + this.input.addEventListener("keydown", this.boundKeydown); + this.input.addEventListener("blur", this.boundBlur); + this.input.addEventListener("focus", this.boundFocus); + } + + disconnectedCallback() { + if (!this.input) { + return; + } + this.input.removeEventListener("input", this.boundInput); + this.input.removeEventListener("keydown", this.boundKeydown); + this.input.removeEventListener("blur", this.boundBlur); + this.input.removeEventListener("focus", this.boundFocus); + } + + handleInput() { + this.scheduleSearch(); + } + + handleFocus() { + if (this.input.value.trim() || this.hasAttribute("suggest-on-focus")) { + this.scheduleSearch(); + } + } + + handleBlur() { + window.setTimeout(() => this.close(), 150); + } + + handleKeydown(ev) { + if (ev.key === "Escape") { + if (!this.listbox.hidden) { + ev.preventDefault(); + this.close(); + } + return; + } + if (ev.key === "ArrowDown") { + ev.preventDefault(); + if (this.listbox.hidden) { + this.scheduleSearch(); + } else { + this.setActiveIndex(this.activeIndex + 1); + } + return; + } + if (ev.key === "ArrowUp") { + ev.preventDefault(); + if (!this.listbox.hidden) { + this.setActiveIndex(this.activeIndex - 1); + } + return; + } + if (ev.key === "Enter" && !this.listbox.hidden && this.activeIndex >= 0) { + ev.preventDefault(); + this.chooseIndex(this.activeIndex); + } + } + + scheduleSearch() { + window.clearTimeout(this.searchTimer); + this.searchTimer = window.setTimeout(() => this.search(), 150); + } + + async search() { + var query = this.input.value.trim(); + var initial = !query && this.hasAttribute("suggest-on-focus"); + if (!query && !initial) { + this.close(); + this.status.textContent = ""; + return; + } + var src = this.getAttribute("src"); + if (!src) { + return; + } + + var url = new URL(src, location.href); + url.searchParams.set("q", query); + if (initial) { + url.searchParams.set("_initial", "1"); + } else { + url.searchParams.delete("_initial"); + } + var fetchId = this.fetchId + 1; + this.fetchId = fetchId; + this.status.textContent = "Searching..."; + + try { + var response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error("HTTP " + response.status); + } + var data = await response.json(); + if (fetchId !== this.fetchId) { + return; + } + this.results = (data && data.rows) || []; + this.render(); + } catch (_error) { + if (fetchId !== this.fetchId) { + return; + } + this.results = []; + this.close(); + this.status.textContent = "Could not load suggestions"; + } + } + + render() { + this.listbox.textContent = ""; + this.activeIndex = -1; + if (!this.results.length) { + this.close(); + this.status.textContent = "No matches"; + return; + } + + this.results.forEach((row, index) => { + var option = document.createElement("div"); + option.className = "datasette-autocomplete-option"; + option.id = this.input.id + "-option-" + index; + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", "false"); + option.dataset.index = String(index); + option.dataset.value = autocompleteValueFromRow(row); + option.textContent = autocompleteLabelFromRow(row); + option.addEventListener("mousedown", (ev) => { + ev.preventDefault(); + this.chooseIndex(index); + }); + this.listbox.appendChild(option); + }); + + this.listbox.hidden = false; + this.input.setAttribute("aria-expanded", "true"); + this.status.textContent = + this.results.length + (this.results.length === 1 ? " match" : " matches"); + this.positionListbox(); + this.setActiveIndex(0); + } + + positionListbox() { + if (!this.input || !this.listbox || this.listbox.hidden) { + return; + } + + var gap = 3; + var margin = 8; + var inputRect = this.input.getBoundingClientRect(); + this.listbox.style.maxHeight = ""; + var defaultMaxHeight = parseFloat( + window.getComputedStyle(this.listbox).maxHeight, + ); + if (!Number.isFinite(defaultMaxHeight)) { + defaultMaxHeight = 256; + } + var scrollHeight = Math.ceil(this.listbox.scrollHeight); + var desiredHeight = Math.min(scrollHeight, defaultMaxHeight); + var availableBelow = Math.max( + 0, + (window.innerHeight || document.documentElement.clientHeight) - + inputRect.bottom - + gap - + margin, + ); + + this.listbox.style.left = inputRect.left + "px"; + this.listbox.style.top = inputRect.bottom + gap + "px"; + this.listbox.style.width = inputRect.width + "px"; + if (scrollHeight <= defaultMaxHeight && scrollHeight <= availableBelow) { + this.listbox.style.maxHeight = "none"; + } else { + this.listbox.style.maxHeight = + Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) + + "px"; + } + window.addEventListener("resize", this.boundPositionListbox); + document.addEventListener("scroll", this.boundPositionListbox, true); + } + + setActiveIndex(index) { + var options = this.listbox.querySelectorAll("[role='option']"); + if (!options.length) { + this.activeIndex = -1; + this.input.removeAttribute("aria-activedescendant"); + return; + } + if (index < 0) { + index = options.length - 1; + } + if (index >= options.length) { + index = 0; + } + options.forEach((option, optionIndex) => { + option.setAttribute( + "aria-selected", + optionIndex === index ? "true" : "false", + ); + }); + this.activeIndex = index; + this.input.setAttribute("aria-activedescendant", options[index].id); + } + + chooseIndex(index) { + var row = this.results[index]; + if (!row) { + return; + } + var value = autocompleteValueFromRow(row); + var label = autocompleteLabelFromRow(row); + this.input.value = value; + this.input.dispatchEvent(new Event("change", { bubbles: true })); + this.close(); + this.status.textContent = "Selected " + label; + this.dispatchEvent( + new CustomEvent("datasette-autocomplete-select", { + bubbles: true, + detail: { + row: row, + value: value, + label: label, + }, + }), + ); + } + + close() { + if (this.listbox) { + this.listbox.hidden = true; + this.listbox.textContent = ""; + this.listbox.style.left = ""; + this.listbox.style.maxHeight = ""; + this.listbox.style.top = ""; + this.listbox.style.width = ""; + } + if (this.input) { + this.input.setAttribute("aria-expanded", "false"); + this.input.removeAttribute("aria-activedescendant"); + } + window.removeEventListener("resize", this.boundPositionListbox); + document.removeEventListener("scroll", this.boundPositionListbox, true); + this.activeIndex = -1; + } + } + + customElements.define("datasette-autocomplete", DatasetteAutocomplete); +})(); diff --git a/datasette/static/column-chooser.js b/datasette/static/column-chooser.js index 133e7cb0..198641f3 100644 --- a/datasette/static/column-chooser.js +++ b/datasette/static/column-chooser.js @@ -31,9 +31,9 @@ class ColumnChooser extends HTMLElement {